17 Commits

Author SHA1 Message Date
funman300 c7c057a31a superproject: bump launcher submodule to consolidated main
Records the store-entry category-clamp hook (launcher 9aecc65) as the
launcher tip on main. Only the launcher gitlink is bumped; core stays
271c363 and bridge stays 0f581eb (both as already recorded), and the
in-tree formatting/doc churn is left untouched.
2026-08-19 15:35:34 +00:00
funman300 89dc1b1d85 sbc: document reversed elgReq ordinal finding; elgReq stays empty
Reversed from the pinned CardsDLL (4706a881): eligibilityKey and
eligibilityOperation are localization ordinals (LOC_SBC_ELG_KEY_%d),
not the atom hex ids. The client's only consumer is the requirement-
display string builder at ~0x1800ef900 (formats via indexed locale
keys, no comparison/gate). The ordinal->string map lives only in the
packed locale (absent from all assets we hold), so any emitted value
would render the WRONG requirement text. Submission stays fully
validated server-side by Core; the empty elgReq is display-only.
Correct ENDPOINT_MAP.md's implied atom-id==ordinal assumption and
pin the exact remaining blocker at the emit site.
2026-08-19 15:00:18 +00:00
funman300 13a22c4507 Bump launcher: plain-language launch status 2026-08-19 04:28:13 +00:00
funman300 1db9acdf6d Bump launcher: persist hook DLL override in the prefix registry
Removes the manual Steam launch-options step; the launcher now writes
version=native,builtin into the Wine prefix so any launch path loads the hook.
2026-08-19 03:01:47 +00:00
funman300 911c7a34fd Bump launcher: promote FIFA17 SBC dispatch (no env arming)
Picks up openfut-launcher 94feaec, which makes the guarded native SBC dispatch
repair a build-armed promoted feature instead of an OPENFUT_SBC_DISPATCH env gate.
Any launch path (Steam, the launcher Launch button, bare umu-run) now gets the
repair, so the SBC screen no longer depends on a harness script exporting a
variable. Every runtime guard is unchanged; rollback is a version.dll file swap.
2026-08-19 02:45:58 +00:00
funman300 fcc314afb1 fifa17 store: render cover art on My Packs reward tiles
A reward pack advertised its own id (70-75) as assetId, which is not a client art
asset, so its My Packs tile rendered blank. Reward tiles now carry their tier
store pack as the cover asset (bronze->1, silver->3, gold->5) while `id` stays the
pack own id (the open packId / SERVER_ID); the sentinel keeps its own id so it
stays an inert placeholder.

LIVE-MAPPED on the retail client 2026-08-18 across all three store tabs and two
reward tiles, which corrected an earlier wrong assumption:
  * assetId only gates whether art renders AT ALL (unknown id -> blank tile).
  * WHICH art is drawn comes from packType + packContentInfo.rareQuantity, NOT
    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 art is content-driven.

So a reward tile now shows the same cover as the equivalent purchasable pack (a
gold reward shows the blue-special art the 5000-coin Gold Pack shows - EA art
advertises an aspirational card rather than the tier colour). Regression test
reward_tiles_carry_a_renderable_cover_asset added; pack70 golden regenerated.
2026-08-19 02:08:37 +00:00
funman300 b323244ac9 fifa17 store: make My Packs reward tiles openable (free coins row)
Reward/My-Packs tiles were emitted with no currencies row (dropped to avoid an
"undefined" payment label). But FIFA 17 opens My Packs through the store PURCHASE
flow (My Packs is a client-side filter over the store catalogue; the open-vs-buy
fork is client-side and no response selects it), so a tile with no purchase path
is not actionable — clicking it navigates instead of opening, sending no request.

Fix: keep the coins currency at the pack price (0 for reward packs; no extPrice,
so no `or %1s` mtx bug). The client then treats the tile as free and clicking
sends POST /purchased, which the server opens for free (owned-only redeem, no
debit). The empty-My-Packs sentinel keeps no currency row so it stays
non-openable. pack70 golden regenerated.

LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards on the
retail client (host log: POST /purchased/items 200 -> GET /purchased/items).
2026-08-19 01:50:51 +00:00
funman300 1d6a6fffcc fifa17 store: make reward packs (SBC/draft/season/...) openable
SBC completion and the other Core reward services grant packs with symbolic
definition_ids ("silver_pack", "gold_pack", ...). The FIFA 17 pack system keys
entirely on numeric catalogue ids, and entitlement_pack_ids / handle_pack_open
resolved definition_ids with definition_id.parse::<u64>(), so every symbolic
reward pack was silently dropped from the openable My Packs list. The unopened
count (recoveredPacks, = entitlement count) still counted them, so the client
showed "you have N packs" but had no tile to open -> "no pack available".

Fix (adapter-layer, Core stays game-neutral): add owned-only reward pack
catalogue entries 71-75 (bronze/silver/gold/rare_gold/icon) and a resolver
owned_pack_id_for_definition() that maps both numeric owned ids and the symbolic
reward names to their numeric owned-only pack. entitlement_pack_ids and the
pack-open entitlement selection now use it, so reward packs render as openable
My Packs tiles and redeem their entitlement for free (consume-once, no debit).

Server-verified on staging: 3 Core reward entitlements now render 3 openable
mypacks tiles matching recoveredPacks=3. Tests: adapter resolver + rendering,
host symbolic-reward open flow; full adapter + host suites green.
2026-08-19 01:27:22 +00:00
funman300 f56aa613da fifa17 SBC: keep repeatable challenges re-enterable after completion
The FIFA 17 client gates challenge re-entry on timesCompleted, not on the
repeatable flag: a nonzero count renders the tile COMPLETED and refuses re-entry
even when repeatable=true. So a repeatable challenge now always projects
timesCompleted=0 (challenges_body) and its set as challengesCompletedCount=0
(sets_body); a non-repeatable challenge keeps its true count and stays locked
once completed. Core keeps the authoritative completion record — economy is
unaffected; this is presentation only.

Live-proven on the retail client 2026-08-18: a completed repeatable Bronze
Upgrade now re-opens for a fresh submit instead of blocking. Regression test
repeatable_completed_challenge_stays_enterable added; adapter + full host suite
(incl. differential and the concurrency race) green.
2026-08-19 01:18:36 +00:00
funman300 8c17f896b4 fifa17 store: native category art via displayGroupAssetId
The all-groups landing (shown on first store entry) renders one tile per group
whose background is the client-bundled packs_backgrounds_%d.dds, selected by
displayGroupAssetId. Live-probed on the retail client 2026-08-18: index 0 is
blank; indices 1/2/3 render real pack art. Assign non-zero per category
(bronze=1, silver=2, gold/mypacks=3) so that landing shows native pack art
instead of blank shields. The persistent tabbed store (MY PACKS/BRONZE/SILVER/
GOLD PACKS) draws pack art from the packs themselves and is unaffected.

LIVE-CONFIRMED end-to-end: native tabbed store renders the real 6-pack catalogue
with correct prices (Gold 5000 / Premium Gold 7500), counts (12 items, 10 gold,
1/3 rares), pack art, and no "or %1s" line.
2026-08-19 00:02:13 +00:00
funman300 c68c10cf04 fifa17 store: real 6-pack economy + full-DB pool; drop extPrice
- store_catalog: replace the invented catalogue with the real always-available
  FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
  real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
  FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
  composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
  FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
  authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.

Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
2026-08-18 23:26:55 +00:00
funman300 7116046195 Lock FIFA17 SBC challenge-squad parser to captured retail wire body
Retail Gate C captured PUT /sbs/challenge/101/squad: 23-slot players[] of
{index,itemData:{id,dream}} plus manager/chemistry/rating/formation siblings.
parse_wire_item_ids already handles it (players[].itemData.id, non-zero only);
update the stale "captures unavailable" note and add a verbatim regression test.
2026-08-18 21:29:47 +00:00
funman300 dcac2c546b Bump launcher: FIFA17 SBC dispatch notifier lifecycle correction 2026-08-18 20:59:07 +00:00
funman300 5c8e2dc0bd Bump launcher: FIFA17 SBC dispatch response-class live vtable fix 2026-08-18 20:53:05 +00:00
funman300 bd03aec82a Gate FIFA17 SBC dispatch acceptance 2026-08-18 20:20:38 +00:00
funman300 e8d1c1ddac test(fifa17): harden SBC retail acceptance 2026-08-18 19:01:33 +00:00
funman300 f9740f640d feat(fifa17): route SBCs through atomic Rust Core 2026-08-18 18:26:35 +00:00
23 changed files with 3231 additions and 705 deletions
Generated
+1
View File
@@ -3210,6 +3210,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tower 0.5.3", "tower 0.5.3",
+19
View File
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.** named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
> confirmed consumer of these fields is the requirement-display string builder at
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
> the empty `elgReq`.
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`, **awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`, `rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.** `awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
+14 -17
View File
@@ -158,7 +158,7 @@ launch() {
local trace_enabled=0 local trace_enabled=0
local request_trace_enabled=0 local request_trace_enabled=0
local notifier_trace_enabled=0 local notifier_trace_enabled=0
local commit_enabled=0 local dispatch_enabled=0
case "$mode" in case "$mode" in
baseline) baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] || [[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -177,14 +177,11 @@ launch() {
request_trace_enabled=1 request_trace_enabled=1
notifier_trace_enabled=1 notifier_trace_enabled=1
;; ;;
commit) dispatch)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] || [[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE" die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1 request_trace_enabled=1
notifier_trace_enabled=1 dispatch_enabled=1
commit_enabled=1
;; ;;
*) die "unknown launch mode: $mode" ;; *) die "unknown launch mode: $mode" ;;
esac esac
@@ -207,7 +204,7 @@ launch() {
done done
mkdir -p "${wine_prefix}/dosdevices" mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:" ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log" note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir" cd "$game_dir"
env \ env \
GAMEID=fifa17 \ GAMEID=fifa17 \
@@ -218,8 +215,8 @@ launch() {
OPENFUT_SBC_TRACE="$trace_enabled" \ OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \ OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \ OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \ OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
OPENFUT_SBC_COMMIT="$commit_enabled" \ OPENFUT_SBC_DISPATCH_TRACE=0 \
OPENFUT_SBC_ARM_ONLY=0 \ OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \ OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -227,7 +224,7 @@ launch() {
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit] Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
inspect Read-only PE/hash/export preflight (default). inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect. build Cross-build the inert FIFA17 hook, then run inspect.
@@ -240,11 +237,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires: Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace launch-trace
Start the single M3 passive factory/deserializer trace; requires: Start the M3-M6 passive parser/request/notifier trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-commit launch-dispatch
Trace and arm the SBC cache only after a validated native parse; requires: Trace and repair only a fully validated native status-999 completion; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
Optional path overrides: Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR, OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -260,7 +257,7 @@ case "${1:-inspect}" in
launch) launch baseline ;; launch) launch baseline ;;
launch-resolve) launch resolve ;; launch-resolve) launch resolve ;;
launch-trace) launch trace ;; launch-trace) launch trace ;;
launch-commit) launch commit ;; launch-dispatch) launch dispatch ;;
-h|--help|help) usage ;; -h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;; *) usage >&2; die "unknown command: $1" ;;
esac esac
+1
View File
@@ -15,6 +15,7 @@ pub mod item;
pub mod non_economy; pub mod non_economy;
pub mod owned_query; pub mod owned_query;
pub mod pack_content; pub mod pack_content;
pub mod sbc;
pub mod squad; pub mod squad;
pub mod squad_ext; pub mod squad_ext;
pub mod squad_projection; pub mod squad_projection;
+71 -49
View File
@@ -7,20 +7,16 @@
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content), //! (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. //! mints the drawn cards into Core, and shapes them onto the wire.
//! //!
//! ## Parity note — Python `open_pack` / `_pack_body` //! ## Policy (real FUT 17 composition, DESIGNED odds)
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`) //!
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then //! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
//! draws `count` items (mostly players); the reveal body wraps them verbatim. //! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`) //! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`). //! is biased toward a special version with probability `special_chance` (a DESIGNED
//! 3. Each drawn player becomes a special with probability `special_chance` //! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
//! (`random.random() < special_chance`). //! the whole pool so a draw is always possible even when the pool lacks that tier.
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras; //! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
//! we deliberately OMIT that mix (Core candidates are player defs — players-only). //! candidates are player defs.
//! 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.
use rand::Rng; use rand::Rng;
@@ -77,50 +73,67 @@ pub struct GeneratedCard {
pub attributes: [u8; 6], pub attributes: [u8; 6],
} }
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and /// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when /// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
/// the pool is empty or the pack awards no cards. /// or the pack awards no cards.
/// ///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from /// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
/// the pack's tier (`gold`), biasing each draw toward a special card with /// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
/// probability `special_chance`. An empty tier or partition falls back to the /// An empty tier falls back to the whole pool (so a draw is always possible).
/// next-wider set so a draw is always possible when the pool is non-empty.
pub fn generate_pack_contents( pub fn generate_pack_contents(
pack: &PackDef, pack: &PackDef,
rng: &mut impl Rng, rng: &mut impl Rng,
pool: &[GeneratedCandidate], pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> { ) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 { if pool.is_empty() || pack.count() == 0 {
return Vec::new(); return Vec::new();
} }
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws let all: Vec<&GeneratedCandidate> = pool.iter().collect();
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`). let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect(); let silver: Vec<&GeneratedCandidate> =
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() { pool.iter().filter(|c| (65..75).contains(&c.rating)).collect();
pool.iter().collect() let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).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 mut out = Vec::with_capacity(pack.count as usize); let mut out = Vec::with_capacity(pack.count() as usize);
for _ in 0..pack.count { 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 want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() { let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special &special
} else if !want_special && !normal.is_empty() { } else if !want_special && !normal.is_empty() {
&normal &normal
} else { } else {
&tier src
}; };
let pick = sub[rng.gen_range(0..sub.len())]; let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card()); out.push(pick.to_card());
} }
out
} }
#[cfg(test)] #[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 { PackDef {
id, id,
name: "Test Pack", name: "Test Pack",
price: 1000, price: 1000,
count, n_bronze,
gold, n_silver,
n_gold,
rares: 0,
category: if n_gold > 0 {
"gold"
} else if n_silver > 0 {
"silver"
} else {
"bronze"
},
special_chance, special_chance,
owned_only: false, owned_only: false,
} }
@@ -171,7 +193,7 @@ mod tests {
#[test] #[test]
fn same_seed_same_output() { fn same_seed_same_output() {
let pool = pool(); 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 a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42); let mut b = StdRng::seed_from_u64(42);
assert_eq!( assert_eq!(
@@ -183,7 +205,7 @@ mod tests {
#[test] #[test]
fn different_seeds_can_diverge() { fn different_seeds_can_diverge() {
let pool = pool(); 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 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); 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. // 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> = let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect(); pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] { 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n); assert_eq!(cards.len() as u64, n);
for c in &cards { for c in &cards {
@@ -212,7 +234,7 @@ mod tests {
#[test] #[test]
fn gold_pack_draws_only_gold_tier() { fn gold_pack_draws_only_gold_tier() {
let pool = pool(); 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!( assert!(
cards.iter().all(|c| c.rating >= 75), cards.iter().all(|c| c.rating >= 75),
@@ -223,7 +245,7 @@ mod tests {
#[test] #[test]
fn bronze_pack_draws_only_bronze_tier() { fn bronze_pack_draws_only_bronze_tier() {
let pool = pool(); 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!( assert!(
cards.iter().all(|c| c.rating < 75), cards.iter().all(|c| c.rating < 75),
@@ -239,7 +261,7 @@ mod tests {
.filter(|c| c.special) .filter(|c| c.special)
.map(|c| c.card_id.as_str()) .map(|c| c.card_id.as_str())
.collect(); .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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards assert!(cards
.iter() .iter()
@@ -248,7 +270,7 @@ mod tests {
#[test] #[test]
fn empty_pool_fails_closed() { 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()); assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
} }
} }
+387
View File
@@ -0,0 +1,387 @@
//! 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` stays empty deliberately. Reversed from the pinned CardsDLL
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
// text — it does NOT validate on them. The ordinal->string map is not
// recoverable from any asset we have, so any value we emit would show the
// WRONG requirement to the player. Submission is validated server-side by
// Core regardless; leaving this empty is display-only, never a correctness
// gap. Populate ONLY once the locale ordinal map is captured.
"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"
);
}
}
+269 -108
View File
@@ -1,95 +1,112 @@
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping. //! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//! //!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` + //! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the //! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0` //! owns coins + unopened packs), so there is no Python dependency and no
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated //! dual-write/split-brain.
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//! //!
//! ## Scope / split-brain safety //! ## Relationship to the Python oracle
//! //!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`] //! The wire *shape* was RE'd from the client and cross-checked against the Python
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is //! oracle's `_pack_body`/`store_catalog`
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust //! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the //! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this //! parser side-effect creates an `"mtx"` currency row and switches on the broken
//! before that economy authority exists would create a dual-write/split-brain. See //! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`). //! Python oracle stays the rollback baseline and is never modified; the
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
//! //!
//! ## Economy-parameter provenance //! ## Economy-parameter provenance
//! //!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT //! Pack prices and tier composition are the real always-available FUT 17
//! EA-authentic (the overnight audit established the store economy is invented). The //! regular-store packs (community-documented on fifauteam). Pack ODDS
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders. //! (`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 serde_json::{json, Value};
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID}; use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy /// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic. /// 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)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef { pub struct PackDef {
pub id: u64, pub id: u64,
pub name: &'static str, pub name: &'static str,
pub price: u64, pub price: u64,
pub count: u64, /// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
pub gold: bool, /// `>= 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, pub special_chance: f64,
/// Reward-only pack (no purchase path): excluded from the normal catalogue, /// Reward-only pack (no purchase path): excluded from the normal catalogue,
/// rendered only when owned (in `unopenedPackIds`). /// rendered only when owned (in `unopenedPackIds`).
pub owned_only: bool, pub owned_only: bool,
} }
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/ impl PackDef {
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a /// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
/// compatibility shim, never a catalogue pack (never purchasable/openable). 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] = &[ pub const PACK_CATALOG: &[PackDef] = &[
PackDef { // ── Bronze category ──
id: 1, PackDef { id: 1, name: "Bronze Pack", price: 400,
name: "Bronze Pack", n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
price: 400, special_chance: 0.01, owned_only: false },
count: 5, PackDef { id: 2, name: "Premium Bronze Pack", price: 750,
gold: false, n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze",
special_chance: 0.005, special_chance: 0.02, owned_only: false },
owned_only: false, // ── Silver category ──
}, PackDef { id: 3, name: "Silver Pack", price: 2500,
PackDef { n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
id: 5, special_chance: 0.015, owned_only: false },
name: "Gold Pack", PackDef { id: 4, name: "Premium Silver Pack", price: 3750,
price: 5000, n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver",
count: 7, special_chance: 0.03, owned_only: false },
gold: true, // ── Gold category ──
special_chance: 0.03, PackDef { id: 5, name: "Gold Pack", price: 5000,
owned_only: false, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
}, special_chance: 0.04, owned_only: false },
PackDef { PackDef { id: 6, name: "Premium Gold Pack", price: 7500,
id: 6, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
name: "Premium Gold", special_chance: 0.06, owned_only: false },
price: 15000, // ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
count: 11, PackDef { id: 70, name: "Reward Gold Pack", price: 0,
gold: true, n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold",
special_chance: 0.08, special_chance: 1.0, owned_only: true },
owned_only: false, PackDef { id: 71, name: "Bronze Pack", price: 0,
}, n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
PackDef { special_chance: 0.01, owned_only: true },
id: 7, PackDef { id: 72, name: "Silver Pack", price: 0,
name: "Special Players Pack", n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
price: 25000, special_chance: 0.02, owned_only: true },
count: 11, PackDef { id: 73, name: "Gold Pack", price: 0,
gold: true, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
special_chance: 1.0, special_chance: 0.05, owned_only: true },
owned_only: false, PackDef { id: 74, name: "Rare Gold Pack", price: 0,
}, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
PackDef { special_chance: 0.10, owned_only: true },
id: 70, PackDef { id: 75, name: "Icon Pack", price: 0,
name: "Reward Special Players Pack", n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold",
price: 0, special_chance: 1.0, owned_only: true },
count: 11,
gold: true,
special_chance: 1.0,
owned_only: true,
},
]; ];
/// Look up a catalogue pack by id (the 65534 sentinel is never present). /// 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) PACK_CATALOG.iter().find(|p| p.id == id)
} }
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`): /// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
/// one of the six hard-coded tokens the client resolves. /// owned-only pack it renders and opens as. Accepts a numeric id (imported
fn category(p: &PackDef) -> &'static str { /// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
if p.special_chance >= 1.0 { /// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
"special" /// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
} else if p.gold { /// `None` and is simply not shown as an openable pack rather than faked.
"gold" pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
} else { if let Ok(numeric) = definition_id.parse::<u64>() {
"bronze" 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 /// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the /// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group. /// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { 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!({ let mut body = json!({
"assetId": p.id, "assetId": art_asset,
"id": p.id, "id": p.id,
"packType": if p.gold { "GOLD" } else { "BRONZE" }, "packType": pack_type,
"description": p.name, "description": p.name,
"state": "active", "state": "active",
"saleType": "promo", "saleType": "promo",
@@ -127,26 +205,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
"purchaseCount": 0, "purchaseCount": 0,
"isPremium": false, "isPremium": false,
"sortPriority": idx, "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 }], "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
"extPrice": {
"finalPrice": { "amount": mtx, "currency": "mtx" },
"originalPrice": { "amount": mtx, "currency": "mtx" },
},
"packContentInfo": { "packContentInfo": {
"bronzeQuantity": if p.gold { 0 } else { p.count }, "bronzeQuantity": p.n_bronze,
"silverQuantity": 0, "silverQuantity": p.n_silver,
"goldQuantity": if p.gold { p.count } else { 0 }, "goldQuantity": p.n_gold,
"rareQuantity": if p.gold { p.count } else { 0 }, "rareQuantity": p.rares,
"itemQuantity": p.count, "itemQuantity": p.count(),
}, },
"unopened": owned, "unopened": owned,
}); });
let obj = body.as_object_mut().expect("pack body is a JSON object"); let obj = body.as_object_mut().expect("pack body is a JSON object");
if owned { if owned {
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx // Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
// objects makes the client render the price label as literal "undefined". // reward packs). My Packs opens through the store purchase flow, so a tile
obj.remove("currencies"); // with no currency row is not actionable — clicking navigates instead of
obj.remove("extPrice"); // 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( obj.insert(
"displayGroup".into(), "displayGroup".into(),
json!({ "value": "mypacks", "priority": idx }), json!({ "value": "mypacks", "priority": idx }),
@@ -165,8 +254,11 @@ pub fn sentinel_body(idx: u64) -> Value {
id: SENTINEL_PACK_ID, id: SENTINEL_PACK_ID,
name: "", name: "",
price: 0, price: 0,
count: 0, n_bronze: 0,
gold: true, n_silver: 0,
n_gold: 0,
rares: 0,
category: "gold",
special_chance: 0.0, special_chance: 0.0,
owned_only: true, owned_only: true,
}; };
@@ -176,13 +268,19 @@ pub fn sentinel_body(idx: u64) -> Value {
.expect("sentinel body is a JSON object"); .expect("sentinel body is a JSON object");
obj.insert("state".into(), json!("active")); obj.insert("state".into(), json!("active"));
obj.insert("unopened".into(), json!(false)); 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 body
} }
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids /// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`): /// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim /// first, then any owned packs, then the empty-My-Packs shim (sentinel for
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]). /// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value { pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
let mut packs: Vec<Value> = PACK_CATALOG let mut packs: Vec<Value> = PACK_CATALOG
.iter() .iter()
@@ -203,10 +301,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! Differential parity against the Python oracle. The fixtures under //! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's //! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match //! (object key order is irrelevant to `serde_json::Value` eq). They track the
//! them semantically (object key order is irrelevant to `serde_json::Value` eq). //! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
//! module header).
use super::*; use super::*;
fn parse(s: &str) -> Value { fn parse(s: &str) -> Value {
@@ -214,7 +313,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_zero_sentinel_matches_oracle() { fn purchasegroup_zero_sentinel_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::Sentinel); let got = build_purchasegroup(&[], StoreMode::Sentinel);
let want = parse(include_str!( let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_sentinel.json" "../../tests/fixtures/purchasegroup_zero_sentinel.json"
@@ -223,7 +322,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_zero_clean_matches_oracle() { fn purchasegroup_zero_clean_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::CleanV1); let got = build_purchasegroup(&[], StoreMode::CleanV1);
let want = parse(include_str!( let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_clean.json" "../../tests/fixtures/purchasegroup_zero_clean.json"
@@ -232,7 +331,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_pack70_matches_oracle() { fn purchasegroup_pack70_matches_golden() {
// Owned pack present -> no sentinel regardless of mode. // Owned pack present -> no sentinel regardless of mode.
let got = build_purchasegroup(&[70], StoreMode::Sentinel); let got = build_purchasegroup(&[70], StoreMode::Sentinel);
let want = parse(include_str!( let want = parse(include_str!(
@@ -247,6 +346,68 @@ mod tests {
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID)); 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] #[test]
fn clean_v1_empty_emits_no_mypacks_group() { fn clean_v1_empty_emits_no_mypacks_group() {
let got = build_purchasegroup(&[], StoreMode::CleanV1); let got = build_purchasegroup(&[], StoreMode::CleanV1);
@@ -256,13 +417,13 @@ mod tests {
.iter() .iter()
.map(|e| e["id"].as_u64().unwrap()) .map(|e| e["id"].as_u64().unwrap())
.collect(); .collect();
assert_eq!(ids, vec![1, 5, 6, 7]); assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
} }
#[test] #[test]
fn category_tokens_are_canonical() { fn category_tokens_are_canonical() {
assert_eq!(category(pack_by_id(1).unwrap()), "bronze"); 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(5).unwrap()), "gold");
assert_eq!(category(pack_by_id(7).unwrap()), "special");
} }
} }
+189 -151
View File
@@ -2,197 +2,235 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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, "assetId": 5,
"id": 70,
"packType": "GOLD",
"description": "Reward Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"currencies": [ "currencies": [
{ {
"finalFunds": 25000, "name": "coins",
"funds": 25000, "funds": 0,
"name": "coins" "finalFunds": 0
} }
], ],
"description": "Special Players Pack", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": true,
"displayGroup": { "displayGroup": {
"value": "special" "value": "mypacks",
}, "priority": 1
"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,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": true
} }
], ],
"timestamp": 1596326400 "timestamp": 1596326400
@@ -2,171 +2,201 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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 "timestamp": 1596326400
@@ -2,197 +2,228 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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, "assetId": 65534,
"description": "",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 65534, "id": 65534,
"isPremium": false, "packType": "GOLD",
"description": "",
"state": "active",
"saleType": "promo",
"limitType": "NONE", "limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"packContentInfo": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0, "goldQuantity": 0,
"itemQuantity": 0,
"rareQuantity": 0, "rareQuantity": 0,
"silverQuantity": 0 "itemQuantity": 0
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "mypacks",
"quantity": 0, "priority": 1
"saleType": "promo", }
"sortPriority": 1,
"state": "active",
"unopened": false
} }
], ],
"timestamp": 1596326400 "timestamp": 1596326400
+130 -1
View File
@@ -3,6 +3,18 @@
//! collide with the live oracle). `core_url` defaults to Bridge's convention. //! collide with the live oracle). `core_url` defaults to Bridge's convention.
use std::env; 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)] #[derive(Debug, Clone)]
pub struct HostConfig { pub struct HostConfig {
@@ -44,6 +56,9 @@ pub struct HostConfig {
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the /// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
/// identity store's parent directory + `active_account.json`. /// identity store's parent directory + `active_account.json`.
pub account_path: String, 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)] #[derive(Debug)]
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
Ok(val) 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 { impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> { pub fn from_env() -> Result<Self, ConfigError> {
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?; 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") let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
.ok() .ok()
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
@@ -89,7 +163,7 @@ impl HostConfig {
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty())) .or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| default_account_path(&identity_store_path)); .unwrap_or_else(|| default_account_path(&identity_store_path));
Ok(HostConfig { Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, listen_addr,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
core_url: env::var("OPENFUT_CORE_URL") core_url: env::var("OPENFUT_CORE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()), .unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
@@ -102,6 +176,7 @@ impl HostConfig {
identity_store_path, identity_store_path,
clientdata_path, clientdata_path,
account_path, account_path,
sbc_post_commit_fault,
}) })
} }
} }
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
.to_string_lossy() .to_string_lossy()
.into_owned() .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 }
);
}
}
+45 -13
View File
@@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{
generate_pack_contents, GeneratedCandidate, GeneratedCard, generate_pack_contents, GeneratedCandidate, GeneratedCard,
}; };
use openfut_adapter_fifa17::fut::squad::SquadWireResolver; 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::{ use crate::{
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError, error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
@@ -244,7 +246,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
"firstPartyStoreId": 0, "firstPartyStoreId": 0,
"groupName": "fifa17", "groupName": "fifa17",
"productId": pid.to_string(), "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, Ok(e) => e,
Err(_) => return error_response(503, "core_unavailable"), Err(_) => return error_response(503, "core_unavailable"),
}; };
// The unopened pack instance is an entitlement whose definition id is the // The unopened pack instance is an entitlement whose definition id resolves
// pack id. Absent → already consumed / never granted: honest empty reveal. // 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 let ent = match ents
.into_iter() .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, Some(e) => e,
None => return json_response(&json!({ "itemData": [] })), None => return json_response(&json!({ "itemData": [] })),
@@ -695,7 +702,7 @@ mod tests {
// ── Store BUY ────────────────────────────────────────────────────────── // ── Store BUY ──────────────────────────────────────────────────────────
#[test] #[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 econ = RecEcon::new(10_000);
let pool = pool(); let pool = pool();
let assets = FakeAssets::for_pool(&pool); let assets = FakeAssets::for_pool(&pool);
@@ -705,23 +712,23 @@ mod tests {
assert_eq!(resp.status, 200); assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap(); let b: Value = serde_json::from_slice(&resp.body).unwrap();
let cpr = &b["createPackResponse"]; let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"], 5); // pack 1 count assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5); assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
assert_eq!(cpr["purchasedPackId"], 1); assert_eq!(cpr["purchasedPackId"], 1);
assert_eq!(cpr["duplicateItemIdList"], json!([])); 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); assert_eq!(econ.coins(), 10_000 - 400);
let purchased = econ.purchased.lock(); let purchased = econ.purchased.lock();
assert_eq!(purchased.len(), 1); assert_eq!(purchased.len(), 1);
assert_eq!(purchased[0].0, 400); 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 { for g in &purchased[0].1 {
assert!(pool.iter().any(|c| c.card_id == g.card_id)); assert!(pool.iter().any(|c| c.card_id == g.card_id));
} }
} }
#[test] #[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 econ = RecEcon::new(10_000);
let pool = pool(); let pool = pool();
let assets = FakeAssets::for_pool(&pool); let assets = FakeAssets::for_pool(&pool);
@@ -729,7 +736,7 @@ mod tests {
let deps = store_deps(&econ, &assets, &ent, &pool); let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2)); let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
let b: Value = serde_json::from_slice(&resp.body).unwrap(); 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); assert_eq!(econ.coins(), 10_000 - 5000);
} }
@@ -860,7 +867,7 @@ mod tests {
assert_eq!(b["firstPartyStoreId"], 0); assert_eq!(b["firstPartyStoreId"], 0);
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
assert_eq!(econ.coins(), 10_000 - 5000); 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] #[test]
@@ -886,6 +893,31 @@ mod tests {
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count 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] #[test]
fn open_owned_70_twice_is_consume_once() { fn open_owned_70_twice_is_consume_once() {
let econ = RecEcon::with_entitlements(4600, &["70"]); let econ = RecEcon::with_entitlements(4600, &["70"]);
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
use crate::market_store::{now_secs, Listing, MarketError, MarketStore}; use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
use crate::pile_store::PileStore; use crate::pile_store::PileStore;
use crate::sold_experiment::{CountMode, SoldExperiment}; 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`). /// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
const TRADE_ID_BASE: i64 = 900_000_000; const TRADE_ID_BASE: i64 = 900_000_000;
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
status, status,
headers: vec![("Content-Type".to_string(), "application/json".to_string())], headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: bytes, body: bytes,
transport: ResponseTransport::Normal,
} }
} }
+12
View File
@@ -697,6 +697,18 @@ impl MarketStore {
.map_err(db)? .map_err(db)?
.rows_affected()) .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)] #[cfg(test)]
+10
View File
@@ -159,6 +159,16 @@ impl PileStore {
.map(|r| r.get::<String, _>("core_item_id")) .map(|r| r.get::<String, _>("core_item_id"))
.collect()) .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)] #[cfg(test)]
@@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String {
refs, 1, refs, 1,
"exactly one BUY refused 461 (statuses {statuses:?})" "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 { for r in &rs {
if r.status == 200 { if r.status == 200 {
assert_eq!( assert_eq!(
@@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String {
.as_array() .as_array()
.unwrap() .unwrap()
.len(), .len(),
5 12
); );
} }
} }
+306 -30
View File
@@ -26,15 +26,15 @@
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both | //! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent | //! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
//! | | | across the credits & massinfo surfaces on each side. | //! | | | 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 pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, | //! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
//! | | | sentinel `state:"active"`. | //! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, | //! | | | {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 | //! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
//! | | | machine (register_capability+open_session+freeze) exactly as | //! | | | machine (register_capability+open_session+freeze). |
//! | | | the oracle's launcher/auth handshake does. | //! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, | //! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", | //! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. | //! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal | //! | 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`. //! 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::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME; use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID}; 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. /// Build a real `Server` with economy authority wired against the seeded Core.
/// Returns the server, a direct Core client for balance/entitlement assertions, /// 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. /// the resolver used to obtain stable wire instance ids, and a valid wire
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) { /// `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 probe = HttpCoreClient::new(base, "fifa17");
let owned = probe.all_owned().expect("core collection"); let owned = probe.all_owned().expect("core collection");
assert!(!owned.is_empty(), "seed must grant a starter 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, PERSONA_ID,
) )
.with_economy(services); .with_economy(services);
(server, probe, 20000) (server, probe, resolver, 20000)
} }
// ─────────────────────────── differential comparison ──────────────────────── // ─────────────────────────── differential comparison ────────────────────────
@@ -409,13 +414,31 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
v 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 /// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
/// Core client + `reqwest::blocking` require this), exactly like the /// Core client + `reqwest::blocking` require this), exactly like the
/// thread-per-connection server — the bridge takes its direct `block_on` path. /// thread-per-connection server — the bridge takes its direct `block_on` path.
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
wait_ready(core_base); wait_ready(core_base);
let http = reqwest::blocking::Client::new(); 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. ── // ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
// Oracle: fresh profile already owns pack 70. Core: grant the "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, None,
) )
.1; .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!( assert_eq!(
pack_ids(&opg), pack_ids(&opg),
vec![1, 5, 6, 7, 70], vec![1, 5, 6, 7, 70],
"oracle owned pack70 id set" "oracle (rollback) placeholder id set"
); );
assert_eq!( assert_eq!(
pack_ids(&rpg), pack_ids(&rpg),
vec![1, 5, 6, 7, 70], vec![1, 2, 3, 4, 5, 6, 70],
"rust owned pack70 id set" "rust authoritative real-catalogue id set"
); );
for b in [&opg, &rpg] { for b in [&opg, &rpg] {
assert!( assert!(
!pack_ids(b).contains(&SENTINEL_PACK_ID), !pack_ids(b).contains(&SENTINEL_PACK_ID),
"no 65534 sentinel while a pack is owned" "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() { // 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 id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" }; let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "packType parity for pack {id}"); 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", "PARITY")); matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ──────────────── // ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
let o_bal0 = oracle.coins(); let o_bal0 = oracle.coins();
@@ -577,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.as_array() .as_array()
.expect("rust itemList") .expect("rust itemList")
.clone(); .clone();
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards"); // STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards"); // 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] { for b in [&o_buy, &r_buy] {
let cpr = &b["createPackResponse"]; let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
assert_eq!( assert_eq!(
cpr["purchasedPackId"].as_i64().unwrap(), cpr["purchasedPackId"].as_i64().unwrap(),
1, 1,
@@ -596,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
assert_eq!( assert_eq!(
client.balance().unwrap() - r_bal0, client.balance().unwrap() - r_bal0,
-400, -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 // Minted wire ids for the item ops that follow (both sides put them in the
// pending purchased pile). // pending purchased pile).
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect(); 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!( assert_eq!(
pack_ids(&r_pg_s), pack_ids(&r_pg_s),
vec![1, 5, 6, 7, SENTINEL_PACK_ID], vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> sentinel" "rust empty (unknown SID) -> real catalogue + sentinel"
); );
for b in [&o_pg_s, &r_pg_s] { for b in [&o_pg_s, &r_pg_s] {
let s = b["purchase"] let s = b["purchase"]
@@ -1165,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.unwrap(); .unwrap();
assert_eq!(s["state"], "active", "sentinel state active"); 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) ─ // ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
// Oracle: register the launcher capability, open a session (auth), present the // 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(); let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
assert_eq!( assert_eq!(
pack_ids(&r_pg_c), pack_ids(&r_pg_c),
vec![1, 5, 6, 7], vec![1, 2, 3, 4, 5, 6],
"rust clean-v1 strips the sentinel" "rust clean-v1 real catalogue, sentinel stripped"
); );
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID)); 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. ──────────────────────────────────── // ── Emit the matrix for the run log. ────────────────────────────────────
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix ====="); 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"); 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn economy_differential_python_oracle() { async fn economy_differential_python_oracle() {
let dir = std::env::temp_dir().join(format!( 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(); std::fs::remove_dir_all(&dir).ok();
outcome.expect("differential thread panicked"); 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");
}
+239 -3
View File
@@ -12,7 +12,7 @@
//! touches the production Core DB, production ports/containers, or `.105`. //! touches the production Core DB, production ports/containers, or `.105`.
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; 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_adapter_fifa17::fut::store_session::StoreMode;
use openfut_identity::JsonIdentityStore; use openfut_identity::JsonIdentityStore;
use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::async_bridge::AsyncBridge;
@@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.as_array() .as_array()
.expect("itemList") .expect("itemList")
.clone(); .clone();
assert_eq!(items.len(), 5, "pack 1 awards 5 cards"); assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 5); assert_eq!(bv["createPackResponse"]["numberItems"], 12);
assert!( assert!(
items[0]["id"].as_i64().unwrap() >= 100_000_000, items[0]["id"].as_i64().unwrap() >= 100_000_000,
"minted wire id above the FIFA floor" "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") .join("active_account.json")
.to_string_lossy() .to_string_lossy()
.into_owned(), .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(); 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 / ──── // ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
// stale reader), through the REAL handle_with_ip dispatch ────── // 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(), "/ut/game/fifa17/tradePile/counts".into(),
b"".to_vec(), 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(),
),
] ]
} }
+12 -2
View File
@@ -278,7 +278,7 @@ def cmd_parse(args):
os.makedirs(out_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, "transactions.jsonl") 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: with open(out_path, "w") as out:
for conn_id in sorted(conns): for conn_id in sorted(conns):
c = conns[conn_id] c = conns[conn_id]
@@ -298,6 +298,9 @@ def cmd_parse(args):
m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl) 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, "?") method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?")
path, _, query = target.partition("?") 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) st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl)
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1) req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0)) res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
@@ -342,9 +345,14 @@ def cmd_parse(args):
total += 1 total += 1
os.chmod(out_path, 0o644) 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)" print("wrote %s (%d transactions across %d connections%s)"
% (out_path, total, len(conns), % (out_path, total, len(conns),
"; %d unpaired messages reported above" % skipped if skipped else "")) "; " + "; ".join(detail) if detail else ""))
return 0 return 0
@@ -587,6 +595,8 @@ def main():
# fixture for the whole response. # fixture for the whole response.
p.add_argument("--max-body", type=int, default=0, p.add_argument("--max-body", type=int, default=0,
help="bytes; 0 = keep every body in full") 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) p.set_defaults(fn=cmd_parse)
s = sub.add_parser("snapshot") s = sub.add_parser("snapshot")
+18 -4
View File
@@ -123,8 +123,8 @@ def main():
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode() body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
reqs = [ reqs = [
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n", b"GET /ut/game/fifa17/sbs/sets 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"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"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 /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", 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: if len(txs) == 4:
check("methods and paths in order", check("methods and paths in order",
[(t["request"]["method"], t["request"]["path"]) for t in txs] == [(t["request"]["method"], t["request"]["path"]) for t in txs] ==
[("GET", "/ut/game/fifa17/userMassInfo"), [("GET", "/ut/game/fifa17/sbs/sets"),
("POST", "/ut/game/fifa17/purchased/items"), ("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
("GET", "/chunked"), ("GET", "/chunked"),
("GET", "/ut/game/fifa17/hub")]) ("GET", "/ut/game/fifa17/hub")])
check("request body preserved byte-for-byte", 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 is not None and "keep=yes" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "") 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() srv.shutdown()
print() print()
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails)) print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))