Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8afb812338 | |||
| 3bb4814760 | |||
| e8d1c1ddac | |||
| f9740f640d |
Generated
+2
@@ -3166,6 +3166,7 @@ dependencies = [
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
@@ -3210,6 +3211,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod item;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
|
||||
@@ -24,9 +24,48 @@ pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
/// `GET …/settings` — the FUT client applies this `configs` array via the applier
|
||||
/// `FUN_18011dc50`, the ONLY writer of the `IS_*` UI gate bytes (each
|
||||
/// `byte = (field == 1)`). A flag never sent is a gate never opened — which is why
|
||||
/// Single-Player Seasons/Draft refuse to open while issuing ZERO server requests
|
||||
/// (`friendlySeasonsEnabled` → slot `[0x16]` → model byte `0x1fd3a`
|
||||
/// `IS_FRIENDLY_SEASON_ENABLED`; RE-confirmed via pyghidra on CardsDLL).
|
||||
///
|
||||
/// Default (`OPENFUT_FUT_SETTINGS` unset/`off`) is the historical empty baseline.
|
||||
/// `OPENFUT_FUT_SETTINGS=gates` populates the array. SAFETY: populating it is what
|
||||
/// makes the applier RUN, and it then writes EVERY gate byte, so the already-live
|
||||
/// store flags MUST be re-asserted here or enabling Seasons would clear the working
|
||||
/// Store (those reach the client today via the Blaze FUT_RS4_CONFIG store, not
|
||||
/// here). Mirrors the audited list in `utas_server.py` `_SETTINGS_KEEP`/`_GATES`.
|
||||
pub fn settings_body() -> Value {
|
||||
json!({ "configs": [] })
|
||||
if std::env::var("OPENFUT_FUT_SETTINGS").as_deref() != Ok("gates") {
|
||||
return json!({ "configs": [] });
|
||||
}
|
||||
// Already-live store flags re-asserted (pinned to their working state).
|
||||
const KEEP: &[&str] = &[
|
||||
"storeEnabled",
|
||||
"storeEnabled_JP",
|
||||
"coinEnabled",
|
||||
"coinEnabled_JP",
|
||||
"cardPackStoreEnabled",
|
||||
"cardPackStoreEnabled_JP",
|
||||
"pointsPackStoreEnabled",
|
||||
"tradingEnabled",
|
||||
];
|
||||
// Mode gates that nothing has ever populated (the point of the change).
|
||||
const GATES: &[&str] = &[
|
||||
"friendlySeasonsEnabled",
|
||||
"enableDraftMode",
|
||||
"enableSinglePlayerDraftMode",
|
||||
"enableOfflineDraftMode",
|
||||
"tournamentQuitEnabled",
|
||||
];
|
||||
let configs: Vec<Value> = KEEP
|
||||
.iter()
|
||||
.chain(GATES.iter())
|
||||
.map(|k| json!({ "type": k, "value": 1 }))
|
||||
.collect();
|
||||
json!({ "configs": configs })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
|
||||
@@ -7,20 +7,16 @@
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Parity note — Python `open_pack` / `_pack_body`
|
||||
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
|
||||
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
|
||||
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
|
||||
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
|
||||
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
|
||||
//! 3. Each drawn player becomes a special with probability `special_chance`
|
||||
//! (`random.random() < special_chance`).
|
||||
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
|
||||
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
|
||||
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
|
||||
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
|
||||
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
|
||||
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
|
||||
//! ## Policy (real FUT 17 composition, DESIGNED odds)
|
||||
//!
|
||||
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
|
||||
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
|
||||
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
|
||||
//! is biased toward a special version with probability `special_chance` (a DESIGNED
|
||||
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
|
||||
//! the whole pool so a draw is always possible even when the pool lacks that tier.
|
||||
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
|
||||
//! candidates are player defs.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
@@ -77,50 +73,67 @@ pub struct GeneratedCard {
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
|
||||
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
|
||||
/// the pool is empty or the pack awards no cards.
|
||||
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
|
||||
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
|
||||
/// or the pack awards no cards.
|
||||
///
|
||||
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
|
||||
/// the pack's tier (`gold`), biasing each draw toward a special card with
|
||||
/// probability `special_chance`. An empty tier or partition falls back to the
|
||||
/// next-wider set so a draw is always possible when the pool is non-empty.
|
||||
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
|
||||
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
|
||||
/// An empty tier falls back to the whole pool (so a draw is always possible).
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count == 0 {
|
||||
if pool.is_empty() || pack.count() == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
|
||||
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
|
||||
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
|
||||
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
|
||||
pool.iter().collect()
|
||||
} else {
|
||||
tier
|
||||
};
|
||||
// Partition the tier by special so `special_chance` can bias a draw; either
|
||||
// partition falls back to the whole tier when empty.
|
||||
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = pack.special_chance.clamp(0.0, 1.0);
|
||||
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
|
||||
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
|
||||
let silver: Vec<&GeneratedCandidate> =
|
||||
pool.iter().filter(|c| (65..75).contains(&c.rating)).collect();
|
||||
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count as usize);
|
||||
for _ in 0..pack.count {
|
||||
let mut out = Vec::with_capacity(pack.count() as usize);
|
||||
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
|
||||
draw_tier(&mut out, &silver, &all, pack.n_silver, pack.special_chance, rng);
|
||||
draw_tier(&mut out, &bronze, &all, pack.n_bronze, pack.special_chance, rng);
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
|
||||
/// biasing each pick toward a special version with probability `chance`. Either the
|
||||
/// special or normal partition falls back to the tier when empty.
|
||||
fn draw_tier(
|
||||
out: &mut Vec<GeneratedCard>,
|
||||
tier: &[&GeneratedCandidate],
|
||||
fallback: &[&GeneratedCandidate],
|
||||
n: u64,
|
||||
chance: f64,
|
||||
rng: &mut impl Rng,
|
||||
) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = chance.clamp(0.0, 1.0);
|
||||
for _ in 0..n {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
&tier
|
||||
src
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,13 +169,22 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
|
||||
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
count,
|
||||
gold,
|
||||
n_bronze,
|
||||
n_silver,
|
||||
n_gold,
|
||||
rares: 0,
|
||||
category: if n_gold > 0 {
|
||||
"gold"
|
||||
} else if n_silver > 0 {
|
||||
"silver"
|
||||
} else {
|
||||
"bronze"
|
||||
},
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
@@ -171,7 +193,7 @@ mod tests {
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
@@ -183,7 +205,7 @@ mod tests {
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
@@ -196,7 +218,7 @@ mod tests {
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, n, true, 0.08);
|
||||
let p = pack(6, 0, 0, n, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
@@ -212,7 +234,7 @@ mod tests {
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 20, true, 0.03);
|
||||
let p = pack(5, 0, 0, 20, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
@@ -223,7 +245,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, false, 0.005);
|
||||
let p = pack(1, 20, 0, 0, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
@@ -239,7 +261,7 @@ mod tests {
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 11, true, 1.0);
|
||||
let p = pack(7, 0, 0, 11, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
@@ -248,7 +270,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 7, true, 0.03);
|
||||
let p = pack(5, 0, 0, 7, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! FIFA 17 Squad Building Challenge wire shapes.
|
||||
//!
|
||||
//! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17
|
||||
//! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const CATEGORY_ID: i64 = 1;
|
||||
pub const CATEGORY_NAME: &str = "Foundations";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChallengeIdentity {
|
||||
pub core_id: &'static str,
|
||||
pub set_id: i64,
|
||||
pub challenge_id: i64,
|
||||
pub priority: i64,
|
||||
}
|
||||
|
||||
pub const CHALLENGES: [ChallengeIdentity; 2] = [
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_bronze_upgrade",
|
||||
set_id: 1,
|
||||
challenge_id: 101,
|
||||
priority: 1,
|
||||
},
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_hybrid_nations",
|
||||
set_id: 2,
|
||||
challenge_id: 201,
|
||||
priority: 2,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChallengeView {
|
||||
pub identity: ChallengeIdentity,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub repeatable: bool,
|
||||
pub times_completed: i64,
|
||||
}
|
||||
|
||||
pub fn identity_for_core(core_id: &str) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.core_id == core_id)
|
||||
}
|
||||
|
||||
pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.challenge_id == challenge_id)
|
||||
}
|
||||
|
||||
/// `GET sbs/sets`: object root; categories, sets and awards are always arrays.
|
||||
pub fn sets_body(challenges: &[ChallengeView]) -> Value {
|
||||
let sets: Vec<Value> = challenges
|
||||
.iter()
|
||||
.map(|challenge| {
|
||||
json!({
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"priority": challenge.identity.priority,
|
||||
"challengesCount": 1,
|
||||
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
|
||||
"awards": [],
|
||||
"hidden": false,
|
||||
"endTime": 4_102_444_800_i64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"categories": [{
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": CATEGORY_NAME,
|
||||
"priority": 1,
|
||||
"sets": sets
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET sbs/setId/{id}/challenges`: object root with a challenge array.
|
||||
pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
let records: Vec<Value> = challenges
|
||||
.iter()
|
||||
.filter(|challenge| challenge.identity.set_id == set_id)
|
||||
.map(|challenge| {
|
||||
// A repeatable challenge is always available to enter again. The FIFA 17
|
||||
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
|
||||
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
|
||||
// repeatable challenge never reports itself as terminally completed here.
|
||||
// Core keeps the true completion record (economy authority); this is
|
||||
// presentation only. Live-proven on the retail client 2026-08-18.
|
||||
let times_completed = if challenge.repeatable {
|
||||
0
|
||||
} else {
|
||||
challenge.times_completed
|
||||
};
|
||||
json!({
|
||||
"challengeId": challenge.identity.challenge_id,
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"index": 0,
|
||||
"type": "OPEN_CHALLENGE",
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"challengeImageId": "",
|
||||
"formation": "f442",
|
||||
"endTime": 0,
|
||||
"repeatable": challenge.repeatable,
|
||||
"trophyId": 0,
|
||||
"status": "OPEN",
|
||||
"timesCompleted": times_completed,
|
||||
"awards": [],
|
||||
"elgReq": []
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "challenges": records })
|
||||
}
|
||||
|
||||
/// Empty-body POST starts a challenge. `squad` is object-root on this response class.
|
||||
pub fn start_body(challenge_id: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"squad": {},
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
/// GET challenge squad. The reversed response requires array containers.
|
||||
pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value {
|
||||
let squad: Vec<Value> = wire_item_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": challenge_id,
|
||||
"squad": squad,
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_body(challenge_id: i64) -> Value {
|
||||
json!({ "id": challenge_id })
|
||||
}
|
||||
|
||||
pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"setId": set_id,
|
||||
"credits": credits,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_packs,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SbcWireError {
|
||||
Json(String),
|
||||
MissingSquad,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SbcWireError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"),
|
||||
Self::MissingSquad => {
|
||||
formatter.write_str("SBC squad body contains no supported squad container")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SbcWireError {}
|
||||
|
||||
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
|
||||
///
|
||||
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
|
||||
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
|
||||
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
|
||||
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
|
||||
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
|
||||
/// the manager, chemistry, rating, formation, dream, and index fields are
|
||||
/// presentation/validation hints and are never consumed. The `squad` array
|
||||
/// container remains accepted for the alternate proven shape. Within either,
|
||||
/// only non-zero `itemData.id` values are interpreted.
|
||||
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
|
||||
let root: Value =
|
||||
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
|
||||
let entries = root
|
||||
.get("players")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| root.get("squad").and_then(Value::as_array))
|
||||
.ok_or(SbcWireError::MissingSquad)?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
if let Some(id) = entry
|
||||
.get("itemData")
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|id| *id != 0)
|
||||
{
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn challenge() -> ChallengeView {
|
||||
ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_containers_match_reversed_fifa17_shapes() {
|
||||
let sets = sets_body(&[challenge()]);
|
||||
assert!(sets.is_object());
|
||||
assert!(sets["categories"].is_array());
|
||||
assert!(sets["categories"][0]["sets"].is_array());
|
||||
assert!(sets["categories"][0]["sets"][0]["awards"].is_array());
|
||||
|
||||
let challenges = challenges_body(1, &[challenge()]);
|
||||
assert!(challenges.is_object());
|
||||
assert!(challenges["challenges"].is_array());
|
||||
assert!(challenges["challenges"][0]["awards"].is_array());
|
||||
assert!(challenges["challenges"][0]["elgReq"].is_array());
|
||||
|
||||
assert!(start_body(101)["squad"].is_object());
|
||||
assert!(start_body(101)["playerRequirements"].is_array());
|
||||
assert!(squad_body(101, &[100_000_001])["squad"].is_array());
|
||||
let submit = submit_body(101, 1, 1234, 1);
|
||||
assert!(submit["grantedChallengeAwards"].is_array());
|
||||
assert!(submit["grantedSetAwards"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeatable_completed_challenge_stays_enterable() {
|
||||
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
|
||||
// a repeatable challenge must always project as not-yet-completed while a
|
||||
// non-repeatable one keeps its true count. Core holds the real record.
|
||||
let repeatable = ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 3,
|
||||
};
|
||||
let once = ChallengeView {
|
||||
identity: CHALLENGES[1],
|
||||
name: "Hybrid Nations".into(),
|
||||
description: "Submit a hybrid squad".into(),
|
||||
repeatable: false,
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
|
||||
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
|
||||
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]);
|
||||
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
|
||||
|
||||
let sets = sets_body(&[repeatable, once]);
|
||||
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
|
||||
let repeatable_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[0].set_id)
|
||||
.unwrap();
|
||||
let once_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[1].set_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repeatable_set["challengesCompletedCount"], 0,
|
||||
"a repeatable set never reports itself terminally completed"
|
||||
);
|
||||
assert_eq!(
|
||||
once_set["challengesCompletedCount"], 1,
|
||||
"a one-shot set counts its single completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_known_squad_containers_and_item_ids() {
|
||||
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]);
|
||||
let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]);
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(br#"{"challengeId":101}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":["#),
|
||||
Err(SbcWireError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_matches_captured_retail_challenge_squad_body() {
|
||||
// Verbatim shape from the 2026-08-18 Gate C retail capture of
|
||||
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
|
||||
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
|
||||
// non-zero player itemData.id values are consumed, in wire order; the
|
||||
// manager and all presentation fields are ignored.
|
||||
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
|
||||
"manager":[{"id":100000427,"dream":false}],
|
||||
"players":[
|
||||
{"index":0,"itemData":{"id":100004227,"dream":false}},
|
||||
{"index":1,"itemData":{"id":100004233,"dream":false}},
|
||||
{"index":2,"itemData":{"id":100001317,"dream":false}},
|
||||
{"index":3,"itemData":{"id":100001531,"dream":false}},
|
||||
{"index":4,"itemData":{"id":100000966,"dream":false}},
|
||||
{"index":5,"itemData":{"id":100001947,"dream":false}},
|
||||
{"index":6,"itemData":{"id":100000169,"dream":false}},
|
||||
{"index":7,"itemData":{"id":100002017,"dream":false}},
|
||||
{"index":8,"itemData":{"id":100002765,"dream":false}},
|
||||
{"index":9,"itemData":{"id":100000147,"dream":false}},
|
||||
{"index":10,"itemData":{"id":100000311,"dream":false}},
|
||||
{"index":11,"itemData":{"id":0,"dream":false}},
|
||||
{"index":12,"itemData":{"id":0,"dream":false}},
|
||||
{"index":13,"itemData":{"id":0,"dream":false}},
|
||||
{"index":14,"itemData":{"id":0,"dream":false}},
|
||||
{"index":15,"itemData":{"id":0,"dream":false}},
|
||||
{"index":16,"itemData":{"id":0,"dream":false}},
|
||||
{"index":17,"itemData":{"id":0,"dream":false}},
|
||||
{"index":18,"itemData":{"id":0,"dream":false}},
|
||||
{"index":19,"itemData":{"id":0,"dream":false}},
|
||||
{"index":20,"itemData":{"id":0,"dream":false}},
|
||||
{"index":21,"itemData":{"id":0,"dream":false}},
|
||||
{"index":22,"itemData":{"id":0,"dream":false}}
|
||||
]}"#;
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(retail).unwrap(),
|
||||
[
|
||||
100_004_227, 100_004_233, 100_001_317, 100_001_531, 100_000_966,
|
||||
100_001_947, 100_000_169, 100_002_017, 100_002_765, 100_000_147,
|
||||
100_000_311
|
||||
],
|
||||
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,112 @@
|
||||
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
|
||||
//!
|
||||
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
|
||||
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
|
||||
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
|
||||
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
|
||||
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
|
||||
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
|
||||
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
|
||||
//! owns coins + unopened packs), so there is no Python dependency and no
|
||||
//! dual-write/split-brain.
|
||||
//!
|
||||
//! ## Scope / split-brain safety
|
||||
//! ## Relationship to the Python oracle
|
||||
//!
|
||||
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
|
||||
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
|
||||
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
|
||||
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
|
||||
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
|
||||
//! before that economy authority exists would create a dual-write/split-brain. See
|
||||
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
|
||||
//! The wire *shape* was RE'd from the client and cross-checked against the Python
|
||||
//! oracle's `_pack_body`/`store_catalog`
|
||||
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
|
||||
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
|
||||
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
|
||||
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
|
||||
//! Python oracle stays the rollback baseline and is never modified; the
|
||||
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
|
||||
//! EA-authentic (the overnight audit established the store economy is invented). The
|
||||
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
|
||||
//! Pack prices and tier composition are the real always-available FUT 17
|
||||
//! regular-store packs (community-documented on fifauteam). Pack ODDS
|
||||
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
|
||||
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
|
||||
|
||||
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
|
||||
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
|
||||
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
|
||||
/// per-tier composition are the real always-available FUT 17 regular-store packs,
|
||||
/// with DESIGNED (not EA-authentic) `special_chance` odds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PackDef {
|
||||
pub id: u64,
|
||||
pub name: &'static str,
|
||||
pub price: u64,
|
||||
pub count: u64,
|
||||
pub gold: bool,
|
||||
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
|
||||
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
|
||||
/// pack's displayed composition matches what its generator draws.
|
||||
pub n_bronze: u64,
|
||||
pub n_silver: u64,
|
||||
pub n_gold: u64,
|
||||
/// `rareQuantity` shown on the tile (wire display only).
|
||||
pub rares: u64,
|
||||
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
|
||||
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
|
||||
pub category: &'static str,
|
||||
/// Per-draw probability the awarded card is a special version (DESIGNED
|
||||
/// placeholder; FUT 17 odds are unrecoverable).
|
||||
pub special_chance: f64,
|
||||
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
|
||||
/// rendered only when owned (in `unopenedPackIds`).
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
|
||||
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
|
||||
/// compatibility shim, never a catalogue pack (never purchasable/openable).
|
||||
impl PackDef {
|
||||
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.n_bronze + self.n_silver + self.n_gold
|
||||
}
|
||||
}
|
||||
|
||||
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
|
||||
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
|
||||
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
|
||||
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
|
||||
/// never purchasable/openable.
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
count: 5,
|
||||
gold: false,
|
||||
special_chance: 0.005,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
count: 7,
|
||||
gold: true,
|
||||
special_chance: 0.03,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 6,
|
||||
name: "Premium Gold",
|
||||
price: 15000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 0.08,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 7,
|
||||
name: "Special Players Pack",
|
||||
price: 25000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 70,
|
||||
name: "Reward Special Players Pack",
|
||||
price: 0,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
// ── Bronze category ──
|
||||
PackDef { id: 1, name: "Bronze Pack", price: 400,
|
||||
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
|
||||
special_chance: 0.01, owned_only: false },
|
||||
PackDef { id: 2, name: "Premium Bronze Pack", price: 750,
|
||||
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze",
|
||||
special_chance: 0.02, owned_only: false },
|
||||
// ── Silver category ──
|
||||
PackDef { id: 3, name: "Silver Pack", price: 2500,
|
||||
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
|
||||
special_chance: 0.015, owned_only: false },
|
||||
PackDef { id: 4, name: "Premium Silver Pack", price: 3750,
|
||||
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver",
|
||||
special_chance: 0.03, owned_only: false },
|
||||
// ── Gold category ──
|
||||
PackDef { id: 5, name: "Gold Pack", price: 5000,
|
||||
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
|
||||
special_chance: 0.04, owned_only: false },
|
||||
PackDef { id: 6, name: "Premium Gold Pack", price: 7500,
|
||||
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
|
||||
special_chance: 0.06, owned_only: false },
|
||||
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
|
||||
PackDef { id: 70, name: "Reward Gold Pack", price: 0,
|
||||
n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold",
|
||||
special_chance: 1.0, owned_only: true },
|
||||
PackDef { id: 71, name: "Bronze Pack", price: 0,
|
||||
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
|
||||
special_chance: 0.01, owned_only: true },
|
||||
PackDef { id: 72, name: "Silver Pack", price: 0,
|
||||
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
|
||||
special_chance: 0.02, owned_only: true },
|
||||
PackDef { id: 73, name: "Gold Pack", price: 0,
|
||||
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
|
||||
special_chance: 0.05, owned_only: true },
|
||||
PackDef { id: 74, name: "Rare Gold Pack", price: 0,
|
||||
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
|
||||
special_chance: 0.10, owned_only: true },
|
||||
PackDef { id: 75, name: "Icon Pack", price: 0,
|
||||
n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold",
|
||||
special_chance: 1.0, owned_only: true },
|
||||
];
|
||||
|
||||
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
|
||||
@@ -97,27 +114,88 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
|
||||
PACK_CATALOG.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
|
||||
/// one of the six hard-coded tokens the client resolves.
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
if p.special_chance >= 1.0 {
|
||||
"special"
|
||||
} else if p.gold {
|
||||
"gold"
|
||||
} else {
|
||||
"bronze"
|
||||
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
|
||||
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
|
||||
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
|
||||
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
|
||||
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
|
||||
/// `None` and is simply not shown as an openable pack rather than faked.
|
||||
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
|
||||
if let Ok(numeric) = definition_id.parse::<u64>() {
|
||||
return pack_by_id(numeric)
|
||||
.filter(|pack| pack.owned_only)
|
||||
.map(|pack| pack.id);
|
||||
}
|
||||
let id = match definition_id {
|
||||
"bronze_pack" => 71,
|
||||
"silver_pack" => 72,
|
||||
"gold_pack" => 73,
|
||||
"rare_gold_pack" => 74,
|
||||
"icon_pack" => 75,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
|
||||
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
|
||||
/// carries its own token; owned/reward packs take `mypacks` instead (see
|
||||
/// [`pack_body`]).
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
p.category
|
||||
}
|
||||
|
||||
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
|
||||
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
|
||||
/// purchase fields and take the `mypacks` display group.
|
||||
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
let mtx = std::cmp::max(1, p.price / 100);
|
||||
let pack_type = match p.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
};
|
||||
// Group background texture: FIFA 17's all-groups landing (shown on first store
|
||||
// entry) renders one tile per group whose art is the client-bundled
|
||||
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
|
||||
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
|
||||
// category and that landing shows native art instead of blank shields. The
|
||||
// persistent tabbed store draws its pack art from the packs themselves and
|
||||
// does not depend on this.
|
||||
let group_bg: u64 = if owned {
|
||||
3
|
||||
} else {
|
||||
match p.category {
|
||||
"gold" => 3,
|
||||
"silver" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
};
|
||||
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
|
||||
// three store tabs and two reward tiles:
|
||||
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
|
||||
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
|
||||
// catalogue asset (1-6) makes art appear.
|
||||
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
|
||||
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
|
||||
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
|
||||
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
|
||||
// unchanged and only rotated the featured player, which proves this.)
|
||||
// So a reward tile automatically shows the same art as the equivalent
|
||||
// purchasable pack; we only need a valid asset, and we use the tier's own store
|
||||
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
|
||||
let art_asset: u64 = if owned {
|
||||
match p.category {
|
||||
"gold" => 5,
|
||||
"silver" => 3,
|
||||
_ => 1,
|
||||
}
|
||||
} else {
|
||||
p.id
|
||||
};
|
||||
let mut body = json!({
|
||||
"assetId": p.id,
|
||||
"assetId": art_asset,
|
||||
"id": p.id,
|
||||
"packType": if p.gold { "GOLD" } else { "BRONZE" },
|
||||
"packType": pack_type,
|
||||
"description": p.name,
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
@@ -127,26 +205,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": idx,
|
||||
"displayGroupAssetId": group_bg,
|
||||
// The tile renders `finalFunds` as its coin price; the HUD balance reads
|
||||
// `funds` from the /credits currencies array. We keep the pair equal.
|
||||
//
|
||||
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
|
||||
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
|
||||
// a real-money price" and switches on the broken `or %1s` label — the
|
||||
// Origin/Dime commerce catalogue that would fill it no longer exists
|
||||
// offline, so every string stays at its constructor default. Omitting the
|
||||
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
|
||||
// experiment #4): it strictly reduces executed client code and leaves every
|
||||
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
|
||||
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
|
||||
"extPrice": {
|
||||
"finalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
"originalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": if p.gold { 0 } else { p.count },
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": if p.gold { p.count } else { 0 },
|
||||
"rareQuantity": if p.gold { p.count } else { 0 },
|
||||
"itemQuantity": p.count,
|
||||
"bronzeQuantity": p.n_bronze,
|
||||
"silverQuantity": p.n_silver,
|
||||
"goldQuantity": p.n_gold,
|
||||
"rareQuantity": p.rares,
|
||||
"itemQuantity": p.count(),
|
||||
},
|
||||
"unopened": owned,
|
||||
});
|
||||
let obj = body.as_object_mut().expect("pack body is a JSON object");
|
||||
if owned {
|
||||
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
|
||||
// objects makes the client render the price label as literal "undefined".
|
||||
obj.remove("currencies");
|
||||
obj.remove("extPrice");
|
||||
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
|
||||
// reward packs). My Packs opens through the store purchase flow, so a tile
|
||||
// with no currency row is not actionable — clicking navigates instead of
|
||||
// opening. With a free coin row the client sends POST /purchased and the
|
||||
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
|
||||
// mtx bug), so the free coin row formats as "0", not an unavailable label.
|
||||
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
|
||||
obj.insert(
|
||||
"displayGroup".into(),
|
||||
json!({ "value": "mypacks", "priority": idx }),
|
||||
@@ -165,8 +254,11 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
id: SENTINEL_PACK_ID,
|
||||
name: "",
|
||||
price: 0,
|
||||
count: 0,
|
||||
gold: true,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 0,
|
||||
rares: 0,
|
||||
category: "gold",
|
||||
special_chance: 0.0,
|
||||
owned_only: true,
|
||||
};
|
||||
@@ -176,13 +268,19 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
.expect("sentinel body is a JSON object");
|
||||
obj.insert("state".into(), json!("active"));
|
||||
obj.insert("unopened".into(), json!(false));
|
||||
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
|
||||
// for an empty My Packs), so it keeps no purchase path.
|
||||
obj.remove("currencies");
|
||||
// Keep the sentinel's own id as its asset: it must render as an inert blank
|
||||
// placeholder, never borrow a real pack's cover.
|
||||
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
|
||||
body
|
||||
}
|
||||
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
|
||||
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
|
||||
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
|
||||
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
|
||||
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
|
||||
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
|
||||
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
let mut packs: Vec<Value> = PACK_CATALOG
|
||||
.iter()
|
||||
@@ -203,10 +301,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Differential parity against the Python oracle. The fixtures under
|
||||
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
|
||||
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
|
||||
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
|
||||
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
|
||||
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
|
||||
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
|
||||
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
|
||||
//! module header).
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Value {
|
||||
@@ -214,7 +313,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_sentinel_matches_oracle() {
|
||||
fn purchasegroup_zero_sentinel_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
|
||||
@@ -223,7 +322,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_clean_matches_oracle() {
|
||||
fn purchasegroup_zero_clean_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_clean.json"
|
||||
@@ -232,7 +331,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_pack70_matches_oracle() {
|
||||
fn purchasegroup_pack70_matches_golden() {
|
||||
// Owned pack present -> no sentinel regardless of mode.
|
||||
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
@@ -247,6 +346,68 @@ mod tests {
|
||||
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
|
||||
// Core reward services grant symbolic pack names; each must resolve to an
|
||||
// owned-only catalogue pack so it renders as an openable My Packs tile.
|
||||
for (def, want) in [
|
||||
("bronze_pack", 71),
|
||||
("silver_pack", 72),
|
||||
("gold_pack", 73),
|
||||
("rare_gold_pack", 74),
|
||||
("icon_pack", 75),
|
||||
] {
|
||||
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
|
||||
assert_eq!(id, want);
|
||||
assert!(
|
||||
pack_by_id(id).unwrap().owned_only,
|
||||
"a reward pack must be owned-only"
|
||||
);
|
||||
}
|
||||
// Imported numeric owned-pack ids resolve to themselves.
|
||||
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
|
||||
// Purchasable (non-owned) numeric ids and unknown names never resolve.
|
||||
assert_eq!(owned_pack_id_for_definition("5"), None);
|
||||
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_tiles_carry_a_renderable_cover_asset() {
|
||||
// A reward pack's own id is not a client art asset, so its My Packs tile
|
||||
// renders blank. Each reward tile must therefore carry a valid catalogue
|
||||
// assetId (its tier's store pack) while `id` stays the open packId.
|
||||
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
|
||||
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
|
||||
let tile = pack_body(pack, 1, true);
|
||||
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
|
||||
assert_eq!(
|
||||
tile["assetId"], want_asset,
|
||||
"reward tile borrows its tier's store-pack cover asset"
|
||||
);
|
||||
assert!(
|
||||
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
|
||||
"the cover asset must be a real purchasable catalogue pack"
|
||||
);
|
||||
}
|
||||
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
|
||||
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
|
||||
// A granted silver reward pack (resolved to id 72) must appear as an owned
|
||||
// My Packs tile, not the non-openable sentinel shim.
|
||||
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
|
||||
let packs = got["purchase"].as_array().unwrap();
|
||||
let reward = packs
|
||||
.iter()
|
||||
.find(|p| p["id"] == 72)
|
||||
.expect("reward pack tile present");
|
||||
assert_eq!(reward["displayGroup"]["value"], "mypacks");
|
||||
assert!(reward["unopened"].as_bool().unwrap());
|
||||
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_v1_empty_emits_no_mypacks_group() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
@@ -256,13 +417,13 @@ mod tests {
|
||||
.iter()
|
||||
.map(|e| e["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![1, 5, 6, 7]);
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_canonical() {
|
||||
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
|
||||
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
|
||||
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
|
||||
assert_eq!(category(pack_by_id(7).unwrap()), "special");
|
||||
}
|
||||
}
|
||||
|
||||
+183
-152
@@ -2,197 +2,228 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"description": "Reward Special Players Pack",
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 70,
|
||||
"isPremium": false,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": true
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+166
-136
@@ -2,171 +2,201 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+183
-152
@@ -2,197 +2,228 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"description": "",
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 65534,
|
||||
"isPremium": false,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+1
-1
Submodule openfut-core updated: 637a21eac1...271c3639ed
@@ -3,6 +3,18 @@
|
||||
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
||||
|
||||
use std::env;
|
||||
const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SbcPostCommitFault {
|
||||
#[default]
|
||||
Off,
|
||||
Drop,
|
||||
Malformed,
|
||||
Delay {
|
||||
millis: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostConfig {
|
||||
@@ -44,6 +56,9 @@ pub struct HostConfig {
|
||||
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
||||
/// identity store's parent directory + `active_account.json`.
|
||||
pub account_path: String,
|
||||
/// Disabled by default. Non-off values require three explicit staging guards;
|
||||
/// see [`parse_sbc_post_commit_fault`].
|
||||
pub sbc_post_commit_fault: SbcPostCommitFault,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn parse_sbc_post_commit_fault(
|
||||
raw: Option<&str>,
|
||||
environment: Option<&str>,
|
||||
ack: Option<&str>,
|
||||
listen_addr: &str,
|
||||
) -> Result<SbcPostCommitFault, ConfigError> {
|
||||
let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") {
|
||||
"off" => return Ok(SbcPostCommitFault::Off),
|
||||
"drop" => SbcPostCommitFault::Drop,
|
||||
"malformed" => SbcPostCommitFault::Malformed,
|
||||
value if value.starts_with("delay:") => {
|
||||
let millis = value["delay:".len()..].parse::<u64>().map_err(|_| {
|
||||
ConfigError(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:<milliseconds>"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
if !(1..=30_000).contains(&millis) {
|
||||
return Err(ConfigError(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(),
|
||||
));
|
||||
}
|
||||
SbcPostCommitFault::Delay { millis }
|
||||
}
|
||||
value => {
|
||||
return Err(ConfigError(format!(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:<milliseconds>; got {value:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if environment != Some("staging") {
|
||||
return Err(ConfigError(
|
||||
"SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(),
|
||||
));
|
||||
}
|
||||
if ack != Some(SBC_FAULT_ACK) {
|
||||
return Err(ConfigError(format!(
|
||||
"SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}"
|
||||
)));
|
||||
}
|
||||
if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") {
|
||||
return Err(ConfigError(
|
||||
"SBC post-commit faults refuse the production UTAS port 8099".into(),
|
||||
));
|
||||
}
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
|
||||
let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?;
|
||||
let sbc_post_commit_fault = parse_sbc_post_commit_fault(
|
||||
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
env::var("OPENFUT_ENVIRONMENT").ok().as_deref(),
|
||||
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
&listen_addr,
|
||||
)?;
|
||||
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
@@ -89,7 +163,7 @@ impl HostConfig {
|
||||
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
||||
Ok(HostConfig {
|
||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
||||
listen_addr,
|
||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||
core_url: env::var("OPENFUT_CORE_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
||||
@@ -102,6 +176,7 @@ impl HostConfig {
|
||||
identity_store_path,
|
||||
clientdata_path,
|
||||
account_path,
|
||||
sbc_post_commit_fault,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sbc_post_commit_faults_require_all_staging_guards() {
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(),
|
||||
SbcPostCommitFault::Off
|
||||
);
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
None,
|
||||
Some(SBC_FAULT_ACK),
|
||||
"127.0.0.1:18199"
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
None,
|
||||
"127.0.0.1:18199"
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:8099"
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:18199"
|
||||
)
|
||||
.unwrap(),
|
||||
SbcPostCommitFault::Drop
|
||||
);
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(
|
||||
Some("delay:25"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:18199"
|
||||
)
|
||||
.unwrap(),
|
||||
SbcPostCommitFault::Delay { millis: 25 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{
|
||||
generate_pack_contents, GeneratedCandidate, GeneratedCard,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{pack_by_id, PackDef};
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||
owned_pack_id_for_definition, pack_by_id, PackDef,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
|
||||
@@ -244,7 +246,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
|
||||
"firstPartyStoreId": 0,
|
||||
"groupName": "fifa17",
|
||||
"productId": pid.to_string(),
|
||||
"purchasePackType": if pack.gold { "GOLD" } else { "BRONZE" },
|
||||
"purchasePackType": match pack.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -269,11 +275,12 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -
|
||||
Ok(e) => e,
|
||||
Err(_) => return error_response(503, "core_unavailable"),
|
||||
};
|
||||
// The unopened pack instance is an entitlement whose definition id is the
|
||||
// pack id. Absent → already consumed / never granted: honest empty reveal.
|
||||
// The unopened pack instance is an entitlement whose definition id resolves
|
||||
// to this owned-only pack id (a numeric id or a symbolic reward-pack name).
|
||||
// Absent → already consumed / never granted: honest empty reveal.
|
||||
let ent = match ents
|
||||
.into_iter()
|
||||
.find(|e| e.definition_id.parse::<u64>().ok() == Some(pid))
|
||||
.find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
|
||||
{
|
||||
Some(e) => e,
|
||||
None => return json_response(&json!({ "itemData": [] })),
|
||||
@@ -695,7 +702,7 @@ mod tests {
|
||||
// ── Store BUY ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn buy_pack1_debits_mints_and_reveals_five_cards() {
|
||||
fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
|
||||
let econ = RecEcon::new(10_000);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
@@ -705,23 +712,23 @@ mod tests {
|
||||
assert_eq!(resp.status, 200);
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let cpr = &b["createPackResponse"];
|
||||
assert_eq!(cpr["numberItems"], 5); // pack 1 count
|
||||
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5);
|
||||
assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
|
||||
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
|
||||
assert_eq!(cpr["purchasedPackId"], 1);
|
||||
assert_eq!(cpr["duplicateItemIdList"], json!([]));
|
||||
// Exactly one atomic debit of the pack price (400) minting 5 items.
|
||||
// Exactly one atomic debit of the pack price (400) minting 12 items.
|
||||
assert_eq!(econ.coins(), 10_000 - 400);
|
||||
let purchased = econ.purchased.lock();
|
||||
assert_eq!(purchased.len(), 1);
|
||||
assert_eq!(purchased[0].0, 400);
|
||||
assert_eq!(purchased[0].1.len(), 5);
|
||||
assert_eq!(purchased[0].1.len(), 12);
|
||||
for g in &purchased[0].1 {
|
||||
assert!(pool.iter().any(|c| c.card_id == g.card_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_pack5_debits_gold_price_and_mints_seven() {
|
||||
fn buy_pack5_debits_gold_price_and_mints_twelve() {
|
||||
let econ = RecEcon::new(10_000);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
@@ -729,7 +736,7 @@ mod tests {
|
||||
let deps = store_deps(&econ, &assets, &ent, &pool);
|
||||
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(b["createPackResponse"]["numberItems"], 7); // pack 5 count
|
||||
assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
|
||||
assert_eq!(econ.coins(), 10_000 - 5000);
|
||||
}
|
||||
|
||||
@@ -860,7 +867,7 @@ mod tests {
|
||||
assert_eq!(b["firstPartyStoreId"], 0);
|
||||
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
|
||||
assert_eq!(econ.coins(), 10_000 - 5000);
|
||||
assert_eq!(econ.purchased.lock()[0].1.len(), 7);
|
||||
assert_eq!(econ.purchased.lock()[0].1.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -886,6 +893,31 @@ mod tests {
|
||||
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_symbolic_silver_reward_redeems_entitlement_without_debit() {
|
||||
// A Core reward grant ("silver_pack") resolves to owned-only pack 72 and
|
||||
// opens for free by consuming its entitlement — the SBC reward-pack fix.
|
||||
let econ = RecEcon::with_entitlements(4600, &["silver_pack"]);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
let ent = Fifa17Entities::default();
|
||||
let deps = store_deps(&econ, &assets, &ent, &pool);
|
||||
let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12));
|
||||
assert_eq!(resp.status, 200);
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(b["packId"], 72);
|
||||
assert_eq!(b["purchasePackType"], "SILVER");
|
||||
assert_eq!(econ.coins(), 4600, "a reward pack opens for free");
|
||||
assert!(
|
||||
econ.entitlements.lock().is_empty(),
|
||||
"the reward entitlement is consumed once"
|
||||
);
|
||||
let redeemed = econ.redeemed.lock();
|
||||
assert_eq!(redeemed.len(), 1);
|
||||
assert_eq!(redeemed[0].0, "e0");
|
||||
assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_owned_70_twice_is_consume_once() {
|
||||
let econ = RecEcon::with_entitlements(4600, &["70"]);
|
||||
|
||||
+1151
-32
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::sold_experiment::{CountMode, SoldExperiment};
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
use crate::{CoreEconomy, CoreError, ResponseTransport, WireResponse};
|
||||
|
||||
/// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
|
||||
const TRADE_ID_BASE: i64 = 900_000_000;
|
||||
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
|
||||
status,
|
||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||
body: bytes,
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -697,6 +697,18 @@ impl MarketStore {
|
||||
.map_err(db)?
|
||||
.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn has_active_for_core_item(&self, core_item_id: &str) -> Result<bool, MarketError> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS( \
|
||||
SELECT 1 FROM listings WHERE core_item_id = ? AND state = 'active' \
|
||||
)",
|
||||
)
|
||||
.bind(core_item_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -159,6 +159,16 @@ impl PileStore {
|
||||
.map(|r| r.get::<String, _>("core_item_id"))
|
||||
.collect())
|
||||
}
|
||||
/// Remove stale presentation metadata after Core has consumed an item.
|
||||
/// Projection paths still intersect Core ownership, so failure is safe.
|
||||
pub async fn remove(&self, core_item_id: &str) -> Result<(), PileError> {
|
||||
sqlx::query("DELETE FROM item_pile WHERE core_item_id = ?")
|
||||
.bind(core_item_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String {
|
||||
refs, 1,
|
||||
"exactly one BUY refused 461 (statuses {statuses:?})"
|
||||
);
|
||||
// The winner minted 5 cards; the loser minted nothing.
|
||||
// The winner minted 12 cards (real Bronze Pack); the loser minted nothing.
|
||||
for r in &rs {
|
||||
if r.status == 200 {
|
||||
assert_eq!(
|
||||
@@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
5
|
||||
12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@
|
||||
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
|
||||
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
|
||||
//! | | | across the credits & massinfo surfaces on each side. |
|
||||
//! | purchasegroup pack70 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. |
|
||||
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, |
|
||||
//! | | | sentinel `state:"active"`. |
|
||||
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, |
|
||||
//! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
|
||||
//! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
|
||||
//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
|
||||
//! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. |
|
||||
//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};|
|
||||
//! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
|
||||
//! | | | machine (register_capability+open_session+freeze) exactly as |
|
||||
//! | | | the oracle's launcher/auth handshake does. |
|
||||
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, |
|
||||
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
|
||||
//! | | | machine (register_capability+open_session+freeze). |
|
||||
//! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
|
||||
//! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
|
||||
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
|
||||
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
|
||||
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal |
|
||||
@@ -95,6 +95,7 @@
|
||||
//! killed on guard drop. Never touches the production Core DB/ports or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||
@@ -310,8 +311,12 @@ fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Val
|
||||
|
||||
/// Build a real `Server` with economy authority wired against the seeded Core.
|
||||
/// Returns the server, a direct Core client for balance/entitlement assertions,
|
||||
/// and a valid wire `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) {
|
||||
/// the resolver used to obtain stable wire instance ids, and a valid wire
|
||||
/// `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(
|
||||
base: &str,
|
||||
dir: &std::path::Path,
|
||||
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
||||
let probe = HttpCoreClient::new(base, "fifa17");
|
||||
let owned = probe.all_owned().expect("core collection");
|
||||
assert!(!owned.is_empty(), "seed must grant a starter collection");
|
||||
@@ -374,7 +379,7 @@ fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClie
|
||||
PERSONA_ID,
|
||||
)
|
||||
.with_economy(services);
|
||||
(server, probe, 20000)
|
||||
(server, probe, resolver, 20000)
|
||||
}
|
||||
|
||||
// ─────────────────────────── differential comparison ────────────────────────
|
||||
@@ -409,13 +414,31 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Preserve every object key, array position, and JSON scalar kind while discarding
|
||||
/// wire-insignificant values such as translated labels and live completion counts.
|
||||
fn json_shape(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => json!("null"),
|
||||
Value::Bool(_) => json!("bool"),
|
||||
Value::Number(_) => json!("number"),
|
||||
Value::String(_) => json!("string"),
|
||||
Value::Array(values) => Value::Array(values.iter().map(json_shape).collect()),
|
||||
Value::Object(values) => Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), json_shape(value)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
|
||||
/// Core client + `reqwest::blocking` require this), exactly like the
|
||||
/// thread-per-connection server — the bridge takes its direct `block_on` path.
|
||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||
let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir);
|
||||
|
||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||
@@ -527,29 +550,43 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
None,
|
||||
)
|
||||
.1;
|
||||
// STORE DIVERGED: Rust is the authoritative store owner and serves the real
|
||||
// 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback
|
||||
// baseline, never modified) still serves the old placeholder set {1,5,6,7,70},
|
||||
// so this is Rust-authoritative, NOT oracle parity.
|
||||
assert_eq!(
|
||||
pack_ids(&opg),
|
||||
vec![1, 5, 6, 7, 70],
|
||||
"oracle owned pack70 id set"
|
||||
"oracle (rollback) placeholder id set"
|
||||
);
|
||||
assert_eq!(
|
||||
pack_ids(&rpg),
|
||||
vec![1, 5, 6, 7, 70],
|
||||
"rust owned pack70 id set"
|
||||
vec![1, 2, 3, 4, 5, 6, 70],
|
||||
"rust authoritative real-catalogue id set"
|
||||
);
|
||||
for b in [&opg, &rpg] {
|
||||
assert!(
|
||||
!pack_ids(b).contains(&SENTINEL_PACK_ID),
|
||||
"no 65534 sentinel while a pack is owned"
|
||||
);
|
||||
// packType parity per id (BRONZE for 1, GOLD for the rest).
|
||||
for p in b["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = if id == 1 { "BRONZE" } else { "GOLD" };
|
||||
assert_eq!(p["packType"], want, "packType parity for pack {id}");
|
||||
}
|
||||
}
|
||||
matrix.push(("purchasegroup pack70", "PARITY"));
|
||||
// packType by side: oracle BRONZE for 1 else GOLD; rust by real category
|
||||
// (1,2 bronze / 3,4 silver / 5,6,70 gold).
|
||||
for p in opg["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = if id == 1 { "BRONZE" } else { "GOLD" };
|
||||
assert_eq!(p["packType"], want, "oracle packType for pack {id}");
|
||||
}
|
||||
for p in rpg["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = match id {
|
||||
1 | 2 => "BRONZE",
|
||||
3 | 4 => "SILVER",
|
||||
_ => "GOLD",
|
||||
};
|
||||
assert_eq!(p["packType"], want, "rust packType for pack {id}");
|
||||
}
|
||||
matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
|
||||
let o_bal0 = oracle.coins();
|
||||
@@ -577,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
.as_array()
|
||||
.expect("rust itemList")
|
||||
.clone();
|
||||
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards");
|
||||
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards");
|
||||
// STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
|
||||
// oracle placeholder awards 5. Both debit the same 400-coin price.
|
||||
assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards");
|
||||
assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards");
|
||||
assert_eq!(
|
||||
o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
|
||||
5,
|
||||
"oracle numberItems==5"
|
||||
);
|
||||
assert_eq!(
|
||||
r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
|
||||
12,
|
||||
"rust numberItems==12"
|
||||
);
|
||||
for b in [&o_buy, &r_buy] {
|
||||
let cpr = &b["createPackResponse"];
|
||||
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
|
||||
assert_eq!(
|
||||
cpr["purchasedPackId"].as_i64().unwrap(),
|
||||
1,
|
||||
@@ -596,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
assert_eq!(
|
||||
client.balance().unwrap() - r_bal0,
|
||||
-400,
|
||||
"rust BUY debits 400"
|
||||
"rust BUY debits 400 (price parity)"
|
||||
);
|
||||
matrix.push(("Store BUY (pack1)", "PARITY"));
|
||||
matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN"));
|
||||
// Minted wire ids for the item ops that follow (both sides put them in the
|
||||
// pending purchased pile).
|
||||
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
|
||||
@@ -1153,8 +1201,8 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
);
|
||||
assert_eq!(
|
||||
pack_ids(&r_pg_s),
|
||||
vec![1, 5, 6, 7, SENTINEL_PACK_ID],
|
||||
"rust empty (unknown SID) -> sentinel"
|
||||
vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
|
||||
"rust empty (unknown SID) -> real catalogue + sentinel"
|
||||
);
|
||||
for b in [&o_pg_s, &r_pg_s] {
|
||||
let s = b["purchase"]
|
||||
@@ -1165,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
.unwrap();
|
||||
assert_eq!(s["state"], "active", "sentinel state active");
|
||||
}
|
||||
matrix.push(("purchasegroup sentinel", "PARITY"));
|
||||
matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
|
||||
// Oracle: register the launcher capability, open a session (auth), present the
|
||||
@@ -1217,11 +1265,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
|
||||
assert_eq!(
|
||||
pack_ids(&r_pg_c),
|
||||
vec![1, 5, 6, 7],
|
||||
"rust clean-v1 strips the sentinel"
|
||||
vec![1, 2, 3, 4, 5, 6],
|
||||
"rust clean-v1 real catalogue, sentinel stripped"
|
||||
);
|
||||
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID));
|
||||
matrix.push(("purchasegroup clean-v1", "PARITY"));
|
||||
matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── Emit the matrix for the run log. ────────────────────────────────────
|
||||
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
|
||||
@@ -1240,6 +1288,202 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
assert_eq!(matrix.len(), 16, "all economy ops classified");
|
||||
}
|
||||
|
||||
/// Compare the complete reversed `/sbs/*` response family against the Python oracle.
|
||||
/// Listing labels and counters deliberately come from Core, so parity is defined as the
|
||||
/// exact key/container/scalar-kind graph. Submission is the one semantic deviation:
|
||||
/// Python acknowledges any body without consuming cards; Rust rejects an empty squad.
|
||||
fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let (server, client, resolver, _sample_resource) = build_econ_server(core_base, dir);
|
||||
let cases = [
|
||||
("GET", "/ut/game/fifa17/sbs/sets", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None),
|
||||
("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
|
||||
("PUT", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101", None),
|
||||
("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad",
|
||||
Some(json!({ "squad": [] })),
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, body) in cases {
|
||||
let oracle_result = oracle.req(method, path, body.clone(), None);
|
||||
let bytes = body
|
||||
.as_ref()
|
||||
.map(|value| serde_json::to_vec(value).unwrap())
|
||||
.unwrap_or_default();
|
||||
let rust_result = rust(&server, method, path, &bytes, None);
|
||||
assert_eq!(
|
||||
rust_result.0, oracle_result.0,
|
||||
"{method} {path} status parity"
|
||||
);
|
||||
assert_eq!(
|
||||
json_shape(&rust_result.1),
|
||||
json_shape(&oracle_result.1),
|
||||
"{method} {path} wire shape parity\nRust: {}\nOracle: {}",
|
||||
rust_result.1,
|
||||
oracle_result.1
|
||||
);
|
||||
match (method, path) {
|
||||
("GET", "/ut/game/fifa17/sbs/sets") => {
|
||||
assert_eq!(rust_result.1["categories"][0]["categoryId"], 1);
|
||||
assert_eq!(oracle_result.1["categories"][0]["categoryId"], 1);
|
||||
let rust_set_ids: Vec<i64> = rust_result.1["categories"][0]["sets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|set| set["setId"].as_i64().unwrap())
|
||||
.collect();
|
||||
let oracle_set_ids: Vec<i64> = oracle_result.1["categories"][0]["sets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|set| set["setId"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(rust_set_ids, [1, 2]);
|
||||
assert_eq!(oracle_set_ids, [1, 2]);
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/1/challenges") => {
|
||||
for body in [&rust_result.1, &oracle_result.1] {
|
||||
assert_eq!(body["challenges"][0]["challengeId"], 101);
|
||||
assert_eq!(body["challenges"][0]["setId"], 1);
|
||||
assert_eq!(body["challenges"][0]["categoryId"], 1);
|
||||
}
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/2/challenges") => {
|
||||
for body in [&rust_result.1, &oracle_result.1] {
|
||||
assert_eq!(body["challenges"][0]["challengeId"], 201);
|
||||
assert_eq!(body["challenges"][0]["setId"], 2);
|
||||
assert_eq!(body["challenges"][0]["categoryId"], 1);
|
||||
}
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/999/challenges") => {
|
||||
assert_eq!(rust_result.1["challenges"], json!([]));
|
||||
assert_eq!(oracle_result.1["challenges"], json!([]));
|
||||
}
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101") => {
|
||||
assert_eq!(rust_result.1["challengeId"], 101);
|
||||
assert_eq!(oracle_result.1["challengeId"], 101);
|
||||
}
|
||||
(method, "/ut/game/fifa17/sbs/challenge/101/squad") => {
|
||||
assert!(method == "GET" || method == "PUT");
|
||||
assert_eq!(rust_result.1["id"], 101);
|
||||
assert_eq!(oracle_result.1["id"], 101);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let submit = json!({ "squad": [] });
|
||||
let oracle_submit = oracle.req(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101",
|
||||
Some(submit.clone()),
|
||||
None,
|
||||
);
|
||||
let rust_submit = rust(
|
||||
&server,
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101",
|
||||
&serde_json::to_vec(&submit).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(oracle_submit.0, 200, "oracle preserves its no-op submit");
|
||||
assert_eq!(
|
||||
json_shape(&oracle_submit.1),
|
||||
json_shape(&json!({
|
||||
"challengeId": 0,
|
||||
"setId": 0,
|
||||
"credits": 0,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": 0,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})),
|
||||
"oracle submit response remains freeze-safe"
|
||||
);
|
||||
assert_eq!(
|
||||
rust_submit.0, 400,
|
||||
"Rust must validate instead of copying the oracle's no-op acceptance"
|
||||
);
|
||||
|
||||
let owned = client.all_owned().expect("SBC differential inventory");
|
||||
let mut selected = Vec::new();
|
||||
for nation in ["Argentina", "Brazil"] {
|
||||
selected.push(
|
||||
owned
|
||||
.iter()
|
||||
.find(|item| item.rating >= 70 && item.nation == nation)
|
||||
.unwrap_or_else(|| panic!("missing {nation} SBC fixture")),
|
||||
);
|
||||
}
|
||||
for item in &owned {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item.rating >= 70
|
||||
&& !selected
|
||||
.iter()
|
||||
.any(|existing| existing.owned_card_id == item.owned_card_id)
|
||||
{
|
||||
selected.push(item);
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11);
|
||||
let successful = json!({
|
||||
"squad": selected
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| json!({
|
||||
"index": index,
|
||||
"itemData": {
|
||||
"id": i64::from(
|
||||
resolver.resolve(item).expect("SBC wire identity").item_id
|
||||
)
|
||||
}
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let before_balance = client.balance().unwrap();
|
||||
let before_packs = client.entitlements().unwrap().len();
|
||||
let oracle_success = oracle.req(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
Some(successful.clone()),
|
||||
None,
|
||||
);
|
||||
let rust_success = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&serde_json::to_vec(&successful).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(oracle_success.0, 200);
|
||||
assert_eq!(rust_success.0, 200);
|
||||
assert_eq!(
|
||||
json_shape(&rust_success.1),
|
||||
json_shape(&oracle_success.1),
|
||||
"successful Rust submit preserves the oracle response class"
|
||||
);
|
||||
assert_eq!(rust_success.1["challengeId"], 201);
|
||||
assert_eq!(rust_success.1["setId"], 2);
|
||||
assert!(
|
||||
client.balance().unwrap() > before_balance,
|
||||
"Core applies challenge and achievement coin rewards"
|
||||
);
|
||||
assert_eq!(
|
||||
client.entitlements().unwrap().len(),
|
||||
before_packs + 1,
|
||||
"Core grants one Hybrid Nations pack"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn economy_differential_python_oracle() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -1281,3 +1525,35 @@ async fn economy_differential_python_oracle() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
outcome.expect("differential thread panicked");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn sbc_differential_python_oracle() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfut-sbc-diff-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db_url = format!("sqlite://{}/sbc.db", dir.display());
|
||||
let (core_handle, core_base) = start_core_seeded(&db_url, true).await;
|
||||
|
||||
let core_base_run = core_base.clone();
|
||||
let dir_run = dir.clone();
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
std::thread::spawn(move || {
|
||||
let mut oracle = Oracle::spawn(&dir_run);
|
||||
oracle.wait_ready();
|
||||
run_sbc_differential(&core_base_run, &oracle, &dir_run);
|
||||
})
|
||||
.join()
|
||||
})
|
||||
.await
|
||||
.expect("join spawn_blocking");
|
||||
|
||||
core_handle.abort();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
outcome.expect("SBC differential thread panicked");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! touches the production Core DB, production ports/containers, or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
|
||||
use openfut_adapter_fifa17::fut::store_session::StoreMode;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
@@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
.as_array()
|
||||
.expect("itemList")
|
||||
.clone();
|
||||
assert_eq!(items.len(), 5, "pack 1 awards 5 cards");
|
||||
assert_eq!(bv["createPackResponse"]["numberItems"], 5);
|
||||
assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
|
||||
assert_eq!(bv["createPackResponse"]["numberItems"], 12);
|
||||
assert!(
|
||||
items[0]["id"].as_i64().unwrap() >= 100_000_000,
|
||||
"minted wire id above the FIFA floor"
|
||||
@@ -769,6 +769,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
||||
.join("active_account.json")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +909,212 @@ async fn from_config_constructs_and_serves_economy() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn sbc_survives_complete_core_and_host_restart() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfut-sbc-restart-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db_url = format!("sqlite://{}/sbc.db", dir.display());
|
||||
|
||||
let (h1, base1) = start_core_seeded(&db_url, true).await;
|
||||
let first_base = base1.clone();
|
||||
let first_dir = dir.clone();
|
||||
let (mut cfg, selected_core_ids, selected_wire_ids) = tokio::task::spawn_blocking(move || {
|
||||
let cfg = from_config(&first_base, &first_dir);
|
||||
let server = Server::from_config(&cfg).expect("first complete host");
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
assert_eq!(club.status, 200);
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
let items = club["itemData"].as_array().expect("club itemData");
|
||||
let entities =
|
||||
Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)).unwrap();
|
||||
let argentina = i64::from(entities.nation_id("Argentina").unwrap());
|
||||
let brazil = i64::from(entities.nation_id("Brazil").unwrap());
|
||||
let mut selected = Vec::new();
|
||||
for nation in [argentina, brazil] {
|
||||
selected.push(
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& item["nation"].as_i64() == Some(nation)
|
||||
})
|
||||
.expect("required hybrid nation")
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
for item in items {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& !selected.iter().any(|existing| existing["id"] == item["id"])
|
||||
{
|
||||
selected.push(item.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11, "deterministic Hybrid Nations squad");
|
||||
let selected_wire_ids: Vec<i64> = selected
|
||||
.iter()
|
||||
.map(|item| item["id"].as_i64().expect("wire id"))
|
||||
.collect();
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("catalog reload");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("identity reload");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
let selected_core_ids: Vec<String> = selected_wire_ids
|
||||
.iter()
|
||||
.map(|wire| {
|
||||
resolver
|
||||
.owned_id_for_wire(*wire)
|
||||
.expect("wire mapping persisted")
|
||||
})
|
||||
.collect();
|
||||
let squad = json!({
|
||||
"squad": selected_wire_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let squad_bytes = serde_json::to_vec(&squad).unwrap();
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201/squad",
|
||||
&[],
|
||||
&squad_bytes,
|
||||
)
|
||||
.status,
|
||||
200
|
||||
);
|
||||
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/201", &[], br#"{}"#);
|
||||
assert_eq!(submitted.status, 200);
|
||||
let submitted: Value = serde_json::from_slice(&submitted.body).unwrap();
|
||||
assert_eq!(submitted["challengeId"], 201);
|
||||
assert_eq!(submitted["credits"], 102_750);
|
||||
assert_eq!(submitted["recoveredPacks"], 1);
|
||||
(cfg, selected_core_ids, selected_wire_ids)
|
||||
})
|
||||
.await
|
||||
.expect("initial complete-host phase");
|
||||
|
||||
h1.abort();
|
||||
let _ = h1.await;
|
||||
let (h2, base2) = start_core_seeded(&db_url, false).await;
|
||||
cfg.core_url = base2.clone();
|
||||
let selected_core_ids_check = selected_core_ids.clone();
|
||||
let selected_wire_ids_check = selected_wire_ids.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let server = Server::from_config(&cfg).expect("restarted complete host");
|
||||
let client = HttpCoreClient::new(&base2, "fifa17");
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
client
|
||||
.sbc_completion_counts()
|
||||
.unwrap()
|
||||
.get("sbc_hybrid_nations")
|
||||
.copied(),
|
||||
Some(1)
|
||||
);
|
||||
let owned = client.all_owned().unwrap();
|
||||
assert!(selected_core_ids_check
|
||||
.iter()
|
||||
.all(|id| { owned.iter().all(|item| item.owned_card_id != *id) }));
|
||||
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
club["itemData"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|item| item["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
let saved = server.handle("GET", "/ut/game/fifa17/sbs/challenge/201/squad", &[], b"");
|
||||
let saved: Value = serde_json::from_slice(&saved.body).unwrap();
|
||||
assert!(saved["squad"].as_array().unwrap().is_empty());
|
||||
let purchased = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
|
||||
let purchased: Value = serde_json::from_slice(&purchased.body).unwrap();
|
||||
assert!(purchased["itemData"].as_array().unwrap().is_empty());
|
||||
for path in ["/ut/game/fifa17/tradePile", "/ut/game/fifa17/watchList"] {
|
||||
let pile = server.handle("GET", path, &[], b"");
|
||||
let pile: Value = serde_json::from_slice(&pile.body).unwrap();
|
||||
assert!(pile["auctionInfo"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|auction| {
|
||||
selected_wire_ids_check
|
||||
.iter()
|
||||
.all(|id| auction["itemData"]["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
}
|
||||
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
|
||||
let active: Value = serde_json::from_slice(&active.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
active["players"].as_array().is_none_or(|players| {
|
||||
players
|
||||
.iter()
|
||||
.all(|slot| slot["itemData"]["id"].as_i64() != Some(*id))
|
||||
})
|
||||
}));
|
||||
|
||||
let replay_body = json!({
|
||||
"squad": selected_wire_ids_check
|
||||
.iter()
|
||||
.map(|id| json!({ "itemData": { "id": id } }))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&[],
|
||||
&serde_json::to_vec(&replay_body).unwrap(),
|
||||
)
|
||||
.status,
|
||||
404,
|
||||
"host rejects consumed wire ids before a duplicate effect"
|
||||
);
|
||||
assert!(matches!(
|
||||
client.submit_sbc("sbc_hybrid_nations", &selected_core_ids_check),
|
||||
Err(openfut_utas_host::CoreError::Status(409))
|
||||
));
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("restart catalog");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("restart identity");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
for (wire, core_id) in selected_wire_ids_check.iter().zip(&selected_core_ids_check) {
|
||||
assert_eq!(
|
||||
resolver.owned_id_for_wire(*wire).as_deref(),
|
||||
Some(core_id.as_str())
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("restart verification");
|
||||
h2.abort();
|
||||
let _ = h2.await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
|
||||
// stale reader), through the REAL handle_with_ip dispatch ──────
|
||||
|
||||
@@ -1040,6 +1247,35 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
|
||||
"/ut/game/fifa17/tradePile/counts".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
// ── FIFA 17 SBC family. Reads, tag acknowledgement, durable squad
|
||||
// saves, challenge start, and submission are all Rust-owned. ──
|
||||
("GET", "/ut/game/fifa17/sbs/sets".into(), b"".to_vec()),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/setId/1/challenges".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
("PUT", "/ut/game/fifa17/sbs/sets/tag".into(), b"{}".to_vec()),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ def cmd_parse(args):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, "transactions.jsonl")
|
||||
|
||||
total, skipped = 0, 0
|
||||
total, skipped, filtered = 0, 0, 0
|
||||
with open(out_path, "w") as out:
|
||||
for conn_id in sorted(conns):
|
||||
c = conns[conn_id]
|
||||
@@ -298,6 +298,9 @@ def cmd_parse(args):
|
||||
m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl)
|
||||
method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?")
|
||||
path, _, query = target.partition("?")
|
||||
if args.path_prefix and not path.startswith(args.path_prefix):
|
||||
filtered += 1
|
||||
continue
|
||||
st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl)
|
||||
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
|
||||
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
|
||||
@@ -342,9 +345,14 @@ def cmd_parse(args):
|
||||
total += 1
|
||||
|
||||
os.chmod(out_path, 0o644)
|
||||
detail = []
|
||||
if skipped:
|
||||
detail.append("%d unpaired messages reported above" % skipped)
|
||||
if filtered:
|
||||
detail.append("%d transactions excluded by path prefix" % filtered)
|
||||
print("wrote %s (%d transactions across %d connections%s)"
|
||||
% (out_path, total, len(conns),
|
||||
"; %d unpaired messages reported above" % skipped if skipped else ""))
|
||||
"; " + "; ".join(detail) if detail else ""))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -587,6 +595,8 @@ def main():
|
||||
# fixture for the whole response.
|
||||
p.add_argument("--max-body", type=int, default=0,
|
||||
help="bytes; 0 = keep every body in full")
|
||||
p.add_argument("--path-prefix", default="",
|
||||
help="emit only transactions whose request path starts with this prefix")
|
||||
p.set_defaults(fn=cmd_parse)
|
||||
|
||||
s = sub.add_parser("snapshot")
|
||||
|
||||
@@ -123,8 +123,8 @@ def main():
|
||||
|
||||
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
|
||||
reqs = [
|
||||
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n"
|
||||
b"GET /ut/game/fifa17/sbs/sets HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"POST /ut/game/fifa17/sbs/challenge/101/squad HTTP/1.1\r\nHost: t\r\n"
|
||||
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
|
||||
b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
|
||||
@@ -180,8 +180,8 @@ def main():
|
||||
if len(txs) == 4:
|
||||
check("methods and paths in order",
|
||||
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
|
||||
[("GET", "/ut/game/fifa17/userMassInfo"),
|
||||
("POST", "/ut/game/fifa17/purchased/items"),
|
||||
[("GET", "/ut/game/fifa17/sbs/sets"),
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
|
||||
("GET", "/chunked"),
|
||||
("GET", "/ut/game/fifa17/hub")])
|
||||
check("request body preserved byte-for-byte",
|
||||
@@ -210,6 +210,20 @@ def main():
|
||||
qtx is not None and "keep=yes" in qtx["request"]["query"],
|
||||
qtx["request"]["query"] if qtx else "")
|
||||
|
||||
print("== path-scoped fixture ==")
|
||||
r = subprocess.run(
|
||||
[sys.executable, TOOL, "parse", "--session", SESSION,
|
||||
"--path-prefix", "/ut/game/fifa17/sbs/"],
|
||||
capture_output=True, text=True)
|
||||
print(" " + r.stdout.strip().replace("\n", "\n "))
|
||||
filtered = [json.loads(l) for l in
|
||||
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
|
||||
check("SBC prefix emits only the two SBC transactions", len(filtered) == 2,
|
||||
"got %d" % len(filtered))
|
||||
check("SBC save body remains byte-exact after scoped parse",
|
||||
len(filtered) == 2 and
|
||||
base64.b64decode(filtered[1]["request"]["body_b64"]) == body)
|
||||
|
||||
srv.shutdown()
|
||||
print()
|
||||
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
|
||||
|
||||
Reference in New Issue
Block a user