feat(host): migrate non-economy UTAS routes to Rust + launcher redesign
Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
(nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.
Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).
Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
This commit is contained in:
@@ -23,8 +23,8 @@ use serde_json::{json, Value};
|
||||
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
|
||||
|
||||
/// One owned item, already classified from the catalog + entity tables by the
|
||||
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id` from the
|
||||
/// reverse entity resolver (None = unresolved nation, bucket skipped).
|
||||
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
|
||||
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClubStatInput {
|
||||
pub kind: ContentKind,
|
||||
@@ -32,6 +32,20 @@ pub struct ClubStatInput {
|
||||
pub rating: i64,
|
||||
pub rare: bool,
|
||||
pub nation_id: Option<i64>,
|
||||
pub league_id: Option<i64>,
|
||||
pub team_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Which entity the per-context (`contextId 3`) buckets are keyed by — the FIFA
|
||||
/// `MY CLUB` sub-screen selector (`fut_club_stats.py::context_rows`):
|
||||
/// * `Nation` — the default screen (year/consumables/club/newcards): nation buckets.
|
||||
/// * `League` — URL `club/stats/country/<id>`: league (leagueId) buckets, tier stats.
|
||||
/// * `Team` — URL `club/stats/league/<id>`: team (teamid) buckets, players/kits/badge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextField {
|
||||
Nation,
|
||||
League,
|
||||
Team,
|
||||
}
|
||||
|
||||
// Stat ids (CardsDLL atom table, fut_club_stats.py VOCAB).
|
||||
@@ -137,9 +151,10 @@ fn is_player(i: &ClubStatInput) -> bool {
|
||||
matches!(i.kind, ContentKind::Player)
|
||||
}
|
||||
|
||||
/// Build the full `{"stat":[…]}` body for club/stats/{year,consumables} — the
|
||||
/// global bucket plus per-nation buckets.
|
||||
pub fn club_stats_body(items: &[ClubStatInput]) -> Value {
|
||||
/// Build the full `{"stat":[…]}` body for a club/stats screen — the global bucket
|
||||
/// (identical for every mode) plus per-context buckets keyed by `ctx`
|
||||
/// (nation / league / team), mirroring `fut_club_stats.py::stats_body`.
|
||||
pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
// ---- global bucket (contextId 1, contextValue 0), sorted by stat id ----
|
||||
let mut g: BTreeMap<i64, i64> = BTreeMap::new();
|
||||
let players: Vec<&ClubStatInput> = items.iter().filter(|i| is_player(i)).collect();
|
||||
@@ -209,25 +224,37 @@ pub fn club_stats_body(items: &[ClubStatInput]) -> Value {
|
||||
|
||||
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
|
||||
|
||||
// ---- per-nation buckets (contextId 3, contextValue = nation id) ----
|
||||
let mut by_nation: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
|
||||
// ---- per-context buckets (contextId 3, contextValue = entity id) ----
|
||||
// Nation/League read the tier set (gold/silver/bronze/rare/kits/badges);
|
||||
// Team (the league screen) reads players/kits/badgeDBid. Mirrors context_rows.
|
||||
let mut by_ctx: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
|
||||
for p in &players {
|
||||
if let Some(nid) = p.nation_id {
|
||||
by_nation.entry(nid).or_default().push(p);
|
||||
let id = match ctx {
|
||||
ContextField::Nation => p.nation_id,
|
||||
ContextField::League => p.league_id,
|
||||
ContextField::Team => p.team_id,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
by_ctx.entry(id).or_default().push(p);
|
||||
}
|
||||
}
|
||||
for (nid, sel) in &by_nation {
|
||||
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
|
||||
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
|
||||
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
|
||||
let rare = sel.iter().filter(|i| i.rare).count() as i64;
|
||||
// Order mirrors the oracle context_rows: gold, silver, bronze, rare, kits, badges.
|
||||
stat.push(row(3, *nid, S_GOLD, gold));
|
||||
stat.push(row(3, *nid, S_SILVER, silver));
|
||||
stat.push(row(3, *nid, S_BRONZE, bronze));
|
||||
stat.push(row(3, *nid, S_RARE, rare));
|
||||
stat.push(row(3, *nid, S_KITS, 0));
|
||||
stat.push(row(3, *nid, S_BADGES, 0));
|
||||
for (cid, sel) in &by_ctx {
|
||||
if ctx == ContextField::Team {
|
||||
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
|
||||
} else {
|
||||
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
|
||||
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
|
||||
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
|
||||
let rare = sel.iter().filter(|i| i.rare).count() as i64;
|
||||
stat.push(row(3, *cid, S_GOLD, gold));
|
||||
stat.push(row(3, *cid, S_SILVER, silver));
|
||||
stat.push(row(3, *cid, S_BRONZE, bronze));
|
||||
stat.push(row(3, *cid, S_RARE, rare));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(3, *cid, S_BADGES, 0));
|
||||
}
|
||||
}
|
||||
|
||||
json!({ "stat": stat })
|
||||
@@ -244,6 +271,8 @@ mod tests {
|
||||
rating,
|
||||
rare,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn staff(subtype: i64) -> ClubStatInput {
|
||||
@@ -253,6 +282,8 @@ mod tests {
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn consumable(subtype: i64) -> ClubStatInput {
|
||||
@@ -262,6 +293,8 @@ mod tests {
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +320,7 @@ mod tests {
|
||||
player(70, true, Some(52)),
|
||||
player(60, false, Some(21)),
|
||||
];
|
||||
let g = global(&club_stats_body(&items));
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 3);
|
||||
assert_eq!(g["playersGold"], 1);
|
||||
assert_eq!(g["playersSilver"], 1);
|
||||
@@ -298,7 +331,7 @@ mod tests {
|
||||
#[test]
|
||||
fn staff_by_family() {
|
||||
let items = vec![staff(6), staff(8), staff(8)]; // 1 gk coach, 2 fitness
|
||||
let g = global(&club_stats_body(&items));
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["staffGKCoach"], 1);
|
||||
assert_eq!(g["staffFitnessCoach"], 2);
|
||||
assert_eq!(g["staff"], 3);
|
||||
@@ -314,7 +347,7 @@ mod tests {
|
||||
consumable(217),
|
||||
consumable(258),
|
||||
];
|
||||
let g = global(&club_stats_body(&items));
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["consumables"], 4);
|
||||
assert_eq!(g["consumablesTrainingGk"], 1);
|
||||
assert_eq!(g["consumablesContractPlayer"], 1);
|
||||
@@ -329,7 +362,7 @@ mod tests {
|
||||
player(80, false, Some(52)),
|
||||
staff(8),
|
||||
];
|
||||
let body = club_stats_body(&items);
|
||||
let body = club_stats_body(&items, ContextField::Nation);
|
||||
let g = global(&body);
|
||||
assert_eq!(g["players"], 2, "staff not counted as player");
|
||||
let buckets: Vec<&Value> = body["stat"]
|
||||
@@ -346,7 +379,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)]));
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
@@ -358,4 +391,40 @@ mod tests {
|
||||
assert_eq!(g[atom], 0, "{atom} present as honest zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn league_and_team_context_modes() {
|
||||
let mut a = player(90, true, Some(52));
|
||||
a.league_id = Some(13);
|
||||
a.team_id = Some(240);
|
||||
let mut b = player(60, false, Some(52));
|
||||
b.league_id = Some(13);
|
||||
b.team_id = Some(9);
|
||||
let items = vec![a, b];
|
||||
|
||||
// country screen -> league (leagueId) buckets, tier set (6 rows).
|
||||
let body = club_stats_body(&items, ContextField::League);
|
||||
let league_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
|
||||
.collect();
|
||||
assert_eq!(league_rows.len(), 6);
|
||||
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
assert_eq!(gold["typeValue"], 1);
|
||||
|
||||
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
|
||||
let body = club_stats_body(&items, ContextField::Team);
|
||||
let team_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 240)
|
||||
.collect();
|
||||
assert_eq!(team_rows.len(), 3);
|
||||
let players = team_rows.iter().find(|r| r["type"] == "players").unwrap();
|
||||
assert_eq!(players["typeValue"], 1);
|
||||
assert!(team_rows.iter().any(|r| r["type"] == "badgeDBid"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,176 @@ pub fn security_question_response(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── POST /openfut/account/sync (Rust-owned) ─────────────────
|
||||
|
||||
/// The launcher `account/sync` request fields, with production defaults already
|
||||
/// applied. Everything is optional in the wire body; missing fields fall back to
|
||||
/// the fixed defaults the launcher expects. `personaId` defaults to the host's
|
||||
/// configured persona (passed in), never a baked-in constant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountSyncRequest {
|
||||
pub persona_id: i64,
|
||||
pub persona_name: String,
|
||||
pub level: i64,
|
||||
pub experience: i64,
|
||||
pub experience_max: i64,
|
||||
pub account_funds: i64,
|
||||
pub account_funds_cap: i64,
|
||||
}
|
||||
|
||||
/// Parse the `account/sync` request body, applying every default. `default_persona`
|
||||
/// is the host's configured persona id (used when `personaId` is absent).
|
||||
pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest {
|
||||
let v: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
|
||||
let int = |key: &str, dflt: i64| v.get(key).and_then(Value::as_i64).unwrap_or(dflt);
|
||||
let persona_name = v
|
||||
.get("personaName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("CAGE")
|
||||
.to_string();
|
||||
AccountSyncRequest {
|
||||
persona_id: int("personaId", default_persona),
|
||||
persona_name,
|
||||
level: int("level", 1),
|
||||
experience: int("experience", 0),
|
||||
experience_max: int("experienceMax", 1000),
|
||||
account_funds: int("accountFunds", 0),
|
||||
account_funds_cap: int("accountFundsCap", 100000),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
||||
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
||||
/// entitlement count), never Python's stale profile funds.
|
||||
pub fn account_sync_body(req: &AccountSyncRequest, coins: i64, unopened_packs: usize) -> Value {
|
||||
json!({
|
||||
"account": {
|
||||
"personaId": req.persona_id,
|
||||
"personaName": req.persona_name,
|
||||
"clubName": "OpenFUT",
|
||||
"clubAbbr": "OFC",
|
||||
"level": req.level,
|
||||
"experience": req.experience,
|
||||
"experienceMax": req.experience_max,
|
||||
"accountFunds": req.account_funds,
|
||||
"accountFundsCap": req.account_funds_cap,
|
||||
"profilePath": "accounts/33068179/fifa17_profile.json",
|
||||
"coins": coins,
|
||||
"unopenedPacks": unopened_packs,
|
||||
},
|
||||
"status": "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────── GET …/userMassInfo (Rust-owned) ─────────────────────
|
||||
|
||||
/// Build the full `GET …/userMassInfo` body entirely in Rust (no Python).
|
||||
///
|
||||
/// `squad` is the Core-projected active squad (`user_mass_info_squad` output), so
|
||||
/// it is byte-for-byte the object `GET …/squad/active` embeds. `coins` and
|
||||
/// `unopened_packs` are the authoritative Core economy values. `userInfo.actives`
|
||||
/// mirrors the squad's `actives` (capped at 5), and `userInfo.squadList` is the
|
||||
/// summary of the current squad.
|
||||
pub fn user_mass_info_body(
|
||||
squad: Value,
|
||||
coins: i64,
|
||||
unopened_packs: usize,
|
||||
persona_id: i64,
|
||||
) -> Value {
|
||||
let actives: Vec<Value> = squad
|
||||
.get("actives")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(5).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
||||
let mut user_info = json!({
|
||||
"personaId": persona_id,
|
||||
"clubName": "OpenFUT",
|
||||
"clubAbbr": "OFC",
|
||||
"established": "2026",
|
||||
"accountCreatedPlatformName": "pc",
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
||||
{"name": "points", "funds": 0, "finalFunds": 0, "active": true},
|
||||
],
|
||||
"won": 0,
|
||||
"draw": 0,
|
||||
"loss": 0,
|
||||
"clubNameChangeAllowed": false,
|
||||
"divisionOffline": 10,
|
||||
"divisionOnline": 10,
|
||||
"purchased": false,
|
||||
"feature": {},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0,
|
||||
"sessionCoinsBankBalance": 0,
|
||||
"actives": actives,
|
||||
"squadList": squad_list,
|
||||
});
|
||||
if unopened_packs > 0 {
|
||||
user_info.as_object_mut().unwrap().insert(
|
||||
"unopenedPacks".into(),
|
||||
json!({"preOrderPacks": 0, "recoveredPacks": unopened_packs}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"pileSizeClientData": {"entries": [{"key": 2, "value": 100}, {"key": 4, "value": 50}]},
|
||||
"settings": {"configs": []},
|
||||
"userData": {},
|
||||
"squad": squad,
|
||||
"userInfo": user_info,
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────── POST /ut/auth (Rust) ──────────────────────────
|
||||
|
||||
/// Format `epoch_secs` (seconds since the Unix epoch) as UTC
|
||||
/// `YYYY-MM-DD HH:MM:SS`. Pure civil-date arithmetic (Howard Hinnant's
|
||||
/// `civil_from_days`), so no `time`/`chrono` dependency is needed.
|
||||
pub fn format_utc_datetime(epoch_secs: i64) -> String {
|
||||
let days = epoch_secs.div_euclid(86_400);
|
||||
let secs_of_day = epoch_secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
|
||||
// civil_from_days: days is a count of days since 1970-01-01.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let year = if month <= 2 { year + 1 } else { year };
|
||||
format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
|
||||
}
|
||||
|
||||
/// The persona a `/ut/auth` request adopts: `nucleusPersonaId` or `nuc` from the
|
||||
/// body (numeric or numeric string), else `None` (the host substitutes its
|
||||
/// configured persona). The client is never refused.
|
||||
pub fn parse_auth_persona(body: &[u8]) -> Option<i64> {
|
||||
let v: Value = serde_json::from_slice(body).ok()?;
|
||||
let field = |key: &str| {
|
||||
v.get(key).and_then(|x| {
|
||||
x.as_i64()
|
||||
.or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||||
})
|
||||
};
|
||||
field("nucleusPersonaId").or_else(|| field("nuc"))
|
||||
}
|
||||
|
||||
/// `POST /ut/auth` response body. `sid` is the freshly minted Rust session id;
|
||||
/// `server_time` is UTC `YYYY-MM-DD HH:MM:SS` (also used for `lastOnlineTime`).
|
||||
pub fn auth_body(sid: &str, server_time: &str) -> Value {
|
||||
json!({
|
||||
"protocol": 1,
|
||||
"sid": sid,
|
||||
"serverTime": server_time,
|
||||
"lastOnlineTime": server_time,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -310,4 +480,103 @@ mod tests {
|
||||
security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS));
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_defaults_and_core_economy() {
|
||||
// Empty body -> every default applied; persona falls back to the host's.
|
||||
let req = parse_account_sync(b"", 33_068_179);
|
||||
assert_eq!(req.persona_id, 33_068_179);
|
||||
assert_eq!(req.persona_name, "CAGE");
|
||||
assert_eq!(req.level, 1);
|
||||
assert_eq!(req.experience_max, 1000);
|
||||
assert_eq!(req.account_funds_cap, 100_000);
|
||||
let body = account_sync_body(&req, 29_859_876, 2);
|
||||
let acc = &body["account"];
|
||||
assert_eq!(acc["clubName"], "OpenFUT");
|
||||
assert_eq!(acc["clubAbbr"], "OFC");
|
||||
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
||||
assert_eq!(acc["coins"], 29_859_876);
|
||||
assert_eq!(acc["unopenedPacks"], 2);
|
||||
assert_eq!(body["status"], "OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_honours_request_overrides() {
|
||||
let req = parse_account_sync(
|
||||
br#"{"personaId":42,"personaName":"X","level":9,"accountFunds":500}"#,
|
||||
33_068_179,
|
||||
);
|
||||
assert_eq!(req.persona_id, 42);
|
||||
assert_eq!(req.persona_name, "X");
|
||||
assert_eq!(req.level, 9);
|
||||
assert_eq!(req.account_funds, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_flat_shape() {
|
||||
let squad = json!({
|
||||
"id": 0,
|
||||
"squadName": "OpenFUT",
|
||||
"formation": "f433",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"rating": 90,
|
||||
"chemistry": 49,
|
||||
"actives": [],
|
||||
"players": [],
|
||||
});
|
||||
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179);
|
||||
// Flat top-level envelope.
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
||||
assert_eq!(body["settings"], json!({"configs": []}));
|
||||
assert_eq!(body["userData"], json!({}));
|
||||
// userInfo economy + club identity.
|
||||
let ui = &body["userInfo"];
|
||||
assert_eq!(ui["personaId"], 33_068_179);
|
||||
assert_eq!(ui["clubName"], "OpenFUT");
|
||||
assert_eq!(ui["clubAbbr"], "OFC");
|
||||
assert_eq!(ui["established"], "2026"); // string, not number
|
||||
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
||||
assert_eq!(ui["currencies"][0]["name"], "coins");
|
||||
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][0]["finalFunds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][1]["name"], "points");
|
||||
assert_eq!(ui["reliability"]["reliability"], 100);
|
||||
assert_eq!(ui["divisionOnline"], 10);
|
||||
assert!(ui.get("unopenedPacks").is_none(), "no packs -> key omitted");
|
||||
assert_eq!(ui["squadList"]["squad"][0]["squadName"], "OpenFUT");
|
||||
// Squad object embedded flat under top-level `squad`.
|
||||
assert_eq!(body["squad"]["squadName"], "OpenFUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_includes_unopened_packs_when_present() {
|
||||
let squad = json!({"id": 0, "actives": [], "players": []});
|
||||
let body = user_mass_info_body(squad, 100, 3, 33_068_179);
|
||||
assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
|
||||
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_datetime_formats_known_epochs() {
|
||||
// 2026-08-17 03:54:47 UTC == 1_786_938_887.
|
||||
assert_eq!(format_utc_datetime(1_786_938_887), "2026-08-17 03:54:47");
|
||||
// Unix epoch.
|
||||
assert_eq!(format_utc_datetime(0), "1970-01-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_persona_and_body() {
|
||||
assert_eq!(
|
||||
parse_auth_persona(br#"{"nucleusPersonaId":33068179}"#),
|
||||
Some(33_068_179)
|
||||
);
|
||||
assert_eq!(parse_auth_persona(br#"{"nuc":"42"}"#), Some(42));
|
||||
assert_eq!(parse_auth_persona(b"{}"), None);
|
||||
let b = auth_body("OPENFUT-SID-DEADBEEF", "2026-08-17 03:54:47");
|
||||
assert_eq!(b["protocol"], 1);
|
||||
assert_eq!(b["sid"], "OPENFUT-SID-DEADBEEF");
|
||||
assert_eq!(b["serverTime"], "2026-08-17 03:54:47");
|
||||
assert_eq!(b["lastOnlineTime"], "2026-08-17 03:54:47");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user