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:
funman300
2026-08-17 16:04:20 +00:00
parent 42fd3c7e90
commit e06fd57211
25 changed files with 2061 additions and 147 deletions
+95 -26
View File
@@ -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");
}
}