Migrate club rename and numeric squad reads
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//! Durable FIFA 17 club identity backed by the shared `fut_account.json` shape.
|
||||
//!
|
||||
//! The Python oracle and the launcher use snake-case keys in one account file.
|
||||
//! Rust owns the rename mutation now, but preserves every unrelated account key
|
||||
//! so Blaze/POW and rollback keep reading the same source of truth.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub const DEFAULT_CLUB_NAME: &str = "OpenFUT";
|
||||
pub const DEFAULT_CLUB_ABBR: &str = "OFC";
|
||||
pub const DEFAULT_ESTABLISHED: &str = "2026";
|
||||
|
||||
const CLUB_NAME_MIN: usize = 5;
|
||||
const CLUB_NAME_MAX: usize = 15;
|
||||
const CLUB_ABBR_MIN: usize = 1;
|
||||
const CLUB_ABBR_MAX: usize = 3;
|
||||
|
||||
/// The FIFA-facing club fields read by `user`, `userMassInfo`, and account sync.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClubIdentity {
|
||||
pub name: String,
|
||||
pub abbr: String,
|
||||
pub established: String,
|
||||
}
|
||||
|
||||
type FileSignature = (u64, u64, i64, i64, u64);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AccountState {
|
||||
raw: Map<String, Value>,
|
||||
club: ClubIdentity,
|
||||
signature: Option<FileSignature>,
|
||||
}
|
||||
|
||||
/// Result of applying a rename body. Every result maps to the protocol's `200 {}`;
|
||||
/// the distinction exists for logging and tests, not for changing wire behavior.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RenameOutcome {
|
||||
Updated,
|
||||
Unchanged,
|
||||
Rejected(&'static str),
|
||||
PersistFailed(String),
|
||||
}
|
||||
|
||||
/// Thread-safe account state persisted by atomic replacement on a valid rename.
|
||||
pub struct AccountStore {
|
||||
path: PathBuf,
|
||||
state: Mutex<AccountState>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
/// Load the shared account file. Missing or malformed files use the proven
|
||||
/// production defaults; the first valid rename creates/repairs the file.
|
||||
pub fn open(path: impl Into<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let raw = read_raw(&path);
|
||||
let club = club_from_raw(&raw);
|
||||
let signature = file_signature(&path);
|
||||
Self {
|
||||
path,
|
||||
state: Mutex::new(AccountState {
|
||||
raw,
|
||||
club,
|
||||
signature,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn club(&self) -> ClubIdentity {
|
||||
let mut state = self.state.lock();
|
||||
self.refresh_if_changed(&mut state);
|
||||
state.club.clone()
|
||||
}
|
||||
|
||||
/// Apply `{clubName, clubAbbr}` with the exact Python-oracle semantics:
|
||||
/// either field may be omitted/null, invalid input is ignored, and the caller
|
||||
/// always acknowledges with `200 {}`. A valid change is durable before it
|
||||
/// becomes visible to Rust readers.
|
||||
pub fn rename_from_body(&self, body: &[u8]) -> RenameOutcome {
|
||||
let value: Value = match serde_json::from_slice(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return RenameOutcome::Rejected("malformed_json"),
|
||||
};
|
||||
let Value::Object(request) = value else {
|
||||
return RenameOutcome::Rejected("body_not_object");
|
||||
};
|
||||
|
||||
let mut state = self.state.lock();
|
||||
self.refresh_if_changed(&mut state);
|
||||
let name = match optional_string(&request, "clubName") {
|
||||
Ok(Some(value)) => value.trim().to_owned(),
|
||||
Ok(None) => state.club.name.clone(),
|
||||
Err(()) => return RenameOutcome::Rejected("club_name_not_string"),
|
||||
};
|
||||
let abbr = match optional_string(&request, "clubAbbr") {
|
||||
Ok(Some(value)) => value.trim().to_owned(),
|
||||
Ok(None) => state.club.abbr.clone(),
|
||||
Err(()) => return RenameOutcome::Rejected("club_abbr_not_string"),
|
||||
};
|
||||
|
||||
if !valid_name(&name) {
|
||||
return RenameOutcome::Rejected("club_name_length");
|
||||
}
|
||||
if !valid_abbr(&abbr) {
|
||||
return RenameOutcome::Rejected("club_abbr_length");
|
||||
}
|
||||
if name == state.club.name && abbr == state.club.abbr {
|
||||
return RenameOutcome::Unchanged;
|
||||
}
|
||||
|
||||
let mut updated = state.raw.clone();
|
||||
updated.insert("club_name".into(), Value::String(name.clone()));
|
||||
updated.insert("club_abbr".into(), Value::String(abbr.clone()));
|
||||
let bytes = match serde_json::to_vec_pretty(&updated) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return RenameOutcome::PersistFailed(error.to_string()),
|
||||
};
|
||||
if let Err(error) = persist_atomically(&self.path, &bytes) {
|
||||
return RenameOutcome::PersistFailed(error.to_string());
|
||||
}
|
||||
|
||||
state.raw = updated;
|
||||
state.club.name = name;
|
||||
state.club.abbr = abbr;
|
||||
state.signature = file_signature(&self.path);
|
||||
RenameOutcome::Updated
|
||||
}
|
||||
|
||||
fn refresh_if_changed(&self, state: &mut AccountState) {
|
||||
let signature = file_signature(&self.path);
|
||||
if signature == state.signature {
|
||||
return;
|
||||
}
|
||||
let raw = read_raw(&self.path);
|
||||
state.club = club_from_raw(&raw);
|
||||
state.raw = raw;
|
||||
state.signature = file_signature(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_raw(path: &Path) -> Map<String, Value> {
|
||||
fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn club_from_raw(raw: &Map<String, Value>) -> ClubIdentity {
|
||||
ClubIdentity {
|
||||
name: valid_stored(raw, "club_name", valid_name)
|
||||
.unwrap_or(DEFAULT_CLUB_NAME)
|
||||
.to_owned(),
|
||||
abbr: valid_stored(raw, "club_abbr", valid_abbr)
|
||||
.unwrap_or(DEFAULT_CLUB_ABBR)
|
||||
.to_owned(),
|
||||
established: stored_established(raw).unwrap_or_else(|| DEFAULT_ESTABLISHED.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_signature(path: &Path) -> Option<FileSignature> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
Some((
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.mtime(),
|
||||
metadata.mtime_nsec(),
|
||||
metadata.len(),
|
||||
))
|
||||
}
|
||||
|
||||
fn optional_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, ()> {
|
||||
match object.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(value)) => Ok(Some(value)),
|
||||
Some(_) => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_stored<'a>(
|
||||
raw: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
valid: fn(&str) -> bool,
|
||||
) -> Option<&'a str> {
|
||||
raw.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| valid(value))
|
||||
}
|
||||
|
||||
fn stored_established(raw: &Map<String, Value>) -> Option<String> {
|
||||
match raw.get("established")? {
|
||||
Value::String(value) if valid_established(value) => Some(value.clone()),
|
||||
Value::Number(value) => value.as_u64().map(|year| year.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_name(value: &str) -> bool {
|
||||
let len = value.chars().count();
|
||||
(CLUB_NAME_MIN..=CLUB_NAME_MAX).contains(&len) && value == value.trim()
|
||||
}
|
||||
|
||||
fn valid_abbr(value: &str) -> bool {
|
||||
let len = value.chars().count();
|
||||
(CLUB_ABBR_MIN..=CLUB_ABBR_MAX).contains(&len) && value == value.trim()
|
||||
}
|
||||
|
||||
fn valid_established(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn persist_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let temp = path.with_extension(format!(
|
||||
"openfut-tmp-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let result = (|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temp, path)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"openfut-account-store-{}-{tag}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_rename_preserves_unrelated_account_fields_and_reopens() {
|
||||
let path = temp_path("persist");
|
||||
let _ = fs::remove_file(&path);
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&json!({
|
||||
"persona_id": 33068179,
|
||||
"persona_name": "CAGE",
|
||||
"club_name": "OpenFUT",
|
||||
"club_abbr": "OFC",
|
||||
"established": 2016,
|
||||
"pow_level": 7
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = AccountStore::open(&path);
|
||||
assert_eq!(
|
||||
store.rename_from_body(br#"{"clubName":" Real FUT ","clubAbbr":"RF"}"#),
|
||||
RenameOutcome::Updated
|
||||
);
|
||||
assert_eq!(
|
||||
store.club(),
|
||||
ClubIdentity {
|
||||
name: "Real FUT".into(),
|
||||
abbr: "RF".into(),
|
||||
established: "2016".into()
|
||||
}
|
||||
);
|
||||
|
||||
let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(raw["persona_id"], 33_068_179);
|
||||
assert_eq!(raw["persona_name"], "CAGE");
|
||||
assert_eq!(raw["pow_level"], 7);
|
||||
assert_eq!(raw["club_name"], "Real FUT");
|
||||
assert_eq!(raw["club_abbr"], "RF");
|
||||
assert_eq!(AccountStore::open(&path).club(), store.club());
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_atomic_replacement_is_reloaded_before_read_and_rename() {
|
||||
let path = temp_path("external-replace");
|
||||
let _ = fs::remove_file(&path);
|
||||
fs::write(
|
||||
&path,
|
||||
serde_json::to_vec(&json!({
|
||||
"club_name": "OpenFUT",
|
||||
"club_abbr": "OFC",
|
||||
"pow_level": 7
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let store = AccountStore::open(&path);
|
||||
|
||||
let replacement = serde_json::to_vec(&json!({
|
||||
"club_name": "Other Club",
|
||||
"club_abbr": "OC",
|
||||
"established": "2015",
|
||||
"pow_level": 11
|
||||
}))
|
||||
.unwrap();
|
||||
persist_atomically(&path, &replacement).unwrap();
|
||||
assert_eq!(
|
||||
store.club(),
|
||||
ClubIdentity {
|
||||
name: "Other Club".into(),
|
||||
abbr: "OC".into(),
|
||||
established: "2015".into()
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store.rename_from_body(br#"{"clubName":"Third Club"}"#),
|
||||
RenameOutcome::Updated
|
||||
);
|
||||
let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(raw["club_name"], "Third Club");
|
||||
assert_eq!(raw["club_abbr"], "OC");
|
||||
assert_eq!(raw["pow_level"], 11);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_invalid_and_malformed_renames_match_oracle_no_change_semantics() {
|
||||
let path = temp_path("reject");
|
||||
let _ = fs::remove_file(&path);
|
||||
let store = AccountStore::open(&path);
|
||||
|
||||
assert_eq!(
|
||||
store.rename_from_body(br#"{"clubName":"Open FUT"}"#),
|
||||
RenameOutcome::Updated
|
||||
);
|
||||
assert_eq!(store.club().abbr, DEFAULT_CLUB_ABBR);
|
||||
let before = store.club();
|
||||
assert_eq!(
|
||||
store.rename_from_body(br#"{"clubName":"x","clubAbbr":"TOOLONG"}"#),
|
||||
RenameOutcome::Rejected("club_name_length")
|
||||
);
|
||||
assert_eq!(
|
||||
store.rename_from_body(br#"{"clubName":3}"#),
|
||||
RenameOutcome::Rejected("club_name_not_string")
|
||||
);
|
||||
assert_eq!(
|
||||
store.rename_from_body(b"not-json"),
|
||||
RenameOutcome::Rejected("malformed_json")
|
||||
);
|
||||
assert_eq!(store.club(), before);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user