diff --git a/src/models/squad.rs b/src/models/squad.rs index fa80c97..d6ec109 100644 --- a/src/models/squad.rs +++ b/src/models/squad.rs @@ -54,3 +54,46 @@ pub struct SquadPlayerInput { pub is_captain: bool, pub is_on_bench: bool, } + +/// A complete squad, as a game client sends it. +/// +/// # Why replacement rather than edits +/// +/// Retail FIFA 17 sends the WHOLE squad on every save — roughly 2 KB carrying +/// every slot, item id and kit number — and a user swapping two players +/// produced nine changed slots across two saves. Slot deltas therefore do not +/// describe what the user did, and any attempt to derive `swap_players` or +/// `move_player` from them would be inventing intent the wire never carried. +/// +/// So the only honest semantic operation is: *this is the squad now*. +/// +/// Empty slots are simply absent from `slots`; a client that models an empty +/// slot as a zero item id must drop it at the adapter boundary rather than +/// sending a player Core would have to special-case. +#[derive(Debug, Clone, Default)] +pub struct SquadReplacement { + pub name: Option, + pub formation: Option, + pub slots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlotAssignment { + pub owned_card_id: String, + /// Core's slot numbering. The adapter maps the game's numbering onto it. + pub slot: i64, + pub is_captain: bool, + pub is_on_bench: bool, +} + +/// Outcome of a replacement. +/// +/// Carries the server's own evaluation and, separately, any disagreement with +/// what the client claimed — never a merged value. +#[derive(Debug, Clone)] +pub struct SquadReplaced { + pub squad: Squad, + pub slots_written: usize, + pub evaluation: crate::services::squad_rules::SquadEvaluation, + pub client_disagreements: Vec, +} diff --git a/src/routes/squad.rs b/src/routes/squad.rs index c206e81..3e5b1b2 100644 --- a/src/routes/squad.rs +++ b/src/routes/squad.rs @@ -52,7 +52,7 @@ pub async fn post_squad( squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?; } - let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; + let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?; Ok(Json(json!({ "squad": squad }))) } diff --git a/src/services/mod.rs b/src/services/mod.rs index f5e7fd1..fa534a5 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -15,5 +15,6 @@ pub mod profile; pub mod sbc; pub mod settings; pub mod squad; +pub mod squad_rules; pub mod statistics; pub mod upgrades; diff --git a/src/services/squad.rs b/src/services/squad.rs index 2d550a2..67976c7 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -3,10 +3,19 @@ use crate::{ error::{AppError, AppResult}, models::{ card::{CardDefinition, OwnedCard}, - squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput}, + squad::{ + SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced, + SquadReplacement, + }, + }, + services::{ + card_db::CardDb, + squad_rules::{ + ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot, + }, }, - services::card_db::CardDb, }; +use std::collections::HashSet; use uuid::Uuid; pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec)> { @@ -191,68 +200,153 @@ pub async fn calculate_chemistry( })) } -pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult { +/// Replace a squad's entire slot assignment, atomically. +/// +/// # Why this exists alongside `save_squad` +/// +/// `save_squad` wrote outside a transaction: it UPDATEd the squad, DELETEd every +/// row from `squad_players`, then INSERTed the new ones one at a time. A failure +/// part-way through left a squad with some of its old players deleted and only +/// some of its new ones written — a state no client asked for and none can +/// detect. It also never checked that the cards being placed belonged to the +/// club, and happily accepted the same card in two slots. +/// +/// Those are acceptable in a single-user REST toy and not acceptable under a +/// real client, so this is the one write path now and `save_squad` delegates to +/// it. +/// +/// # Order of work +/// +/// Validation happens BEFORE any write, so a rejected replacement leaves the +/// existing squad exactly as it was. Everything that does write happens inside +/// one transaction. +pub async fn replace_squad( + pool: &Pool, + card_db: &CardDb, + rules: &dyn SquadRules, + club_id: &str, + squad_id: Option<&str>, + replacement: &SquadReplacement, + client_reported: &ClientReportedEvaluation, +) -> AppResult { + // ── validate before touching anything ──────────────────────────────── + let mut seen: HashSet<&str> = HashSet::new(); + let mut slots_seen: HashSet = HashSet::new(); + for s in &replacement.slots { + if s.slot < 0 { + return Err(AppError::BadRequest(format!( + "slot index must not be negative, got {}", + s.slot + ))); + } + if !slots_seen.insert(s.slot) { + return Err(AppError::BadRequest(format!( + "slot {} assigned more than once", + s.slot + ))); + } + if !seen.insert(s.owned_card_id.as_str()) { + return Err(AppError::BadRequest(format!( + "card {} assigned to more than one slot", + s.owned_card_id + ))); + } + } + + // Ownership: every card must belong to THIS club. Without this a client + // could place a card it does not own, and the squad would read back as + // though it did. + let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new(); + for s in &replacement.slots { + let owned = sqlx::query_as::<_, OwnedCard>( + "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?", + ) + .bind(&s.owned_card_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?; + + if owned.club_id != club_id { + // Deliberately the same message as "not found": whether a card + // exists in someone else's club is not this caller's business. + return Err(AppError::NotFound(format!( + "owned card {} not found", + s.owned_card_id + ))); + } + resolved.push(( + SlotAssignmentRef { + slot: s.slot, + is_captain: s.is_captain, + is_on_bench: s.is_on_bench, + }, + owned, + )); + } + + // ── one transaction for every write ────────────────────────────────── let now = chrono::Utc::now().to_rfc3339(); + let mut tx = pool.begin().await?; - let squad_id = if let Some(ref id) = req.squad_id { - // Update existing squad — verify ownership - let verified = - sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?") - .bind(id) - .bind(club_id) - .fetch_optional(pool) - .await? - .ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?; + let squad_id = match squad_id { + Some(id) => { + let verified = sqlx::query_scalar::<_, String>( + "SELECT id FROM squads WHERE id = ? AND club_id = ?", + ) + .bind(id) + .bind(club_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?; - sqlx::query( - "UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?", - ) - .bind(req.name.as_deref()) - .bind(req.formation.as_deref()) - .bind(&now) - .bind(&verified) - .execute(pool) - .await?; - - sqlx::query("DELETE FROM squad_players WHERE squad_id = ?") + sqlx::query( + "UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?", + ) + .bind(replacement.name.as_deref()) + .bind(replacement.formation.as_deref()) + .bind(&now) .bind(&verified) - .execute(pool) + .execute(&mut *tx) .await?; - - verified - } else { - // Create a new squad - let squad = Squad::new( - club_id, - req.name.as_deref().unwrap_or("My Squad"), - req.formation.as_deref().unwrap_or("4-4-2"), - ); - sqlx::query( - "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(&squad.id) - .bind(&squad.club_id) - .bind(&squad.name) - .bind(&squad.formation) - .bind(&squad.created_at) - .bind(&squad.updated_at) - .execute(pool) - .await?; - squad.id + verified + } + None => { + let squad = Squad::new( + club_id, + replacement.name.as_deref().unwrap_or("My Squad"), + replacement.formation.as_deref().unwrap_or("4-4-2"), + ); + sqlx::query( + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&squad.id) + .bind(&squad.club_id) + .bind(&squad.name) + .bind(&squad.formation) + .bind(&squad.created_at) + .bind(&squad.updated_at) + .execute(&mut *tx) + .await?; + squad.id + } }; - for player in &req.players { - let sp_id = Uuid::new_v4().to_string(); + sqlx::query("DELETE FROM squad_players WHERE squad_id = ?") + .bind(&squad_id) + .execute(&mut *tx) + .await?; + + for (slot, owned) in &resolved { sqlx::query( "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)", ) - .bind(&sp_id) + .bind(Uuid::new_v4().to_string()) .bind(&squad_id) - .bind(&player.owned_card_id) - .bind(player.position_index) - .bind(player.is_captain) - .bind(player.is_on_bench) - .execute(pool) + .bind(&owned.id) + .bind(slot.slot) + .bind(slot.is_captain) + .bind(slot.is_on_bench) + .execute(&mut *tx) .await?; } @@ -260,10 +354,87 @@ pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> A "SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?", ) .bind(&squad_id) - .fetch_one(pool) + .fetch_one(&mut *tx) .await?; - Ok(squad) + tx.commit().await?; + + // ── evaluate with the game's rules, never with the client's numbers ── + let snapshot = SquadSnapshot { + formation: squad.formation.clone(), + players: resolved + .iter() + .filter_map(|(slot, owned)| { + card_db.get(&owned.card_id).map(|card| SquadPlayerCard { + owned_card_id: owned.id.clone(), + card_id: card.id.clone(), + name: card.name.clone(), + overall: card.overall, + position: card.position.clone(), + nation: card.nation.clone(), + league: card.league.clone(), + club: card.club.clone(), + slot: slot.slot, + on_bench: slot.is_on_bench, + }) + }) + .collect(), + }; + let evaluation = rules.evaluate(&snapshot); + let client_disagreements = client_reported.compare(&evaluation); + + Ok(SquadReplaced { + squad, + slots_written: resolved.len(), + evaluation, + client_disagreements, + }) +} + +struct SlotAssignmentRef { + slot: i64, + is_captain: bool, + is_on_bench: bool, +} + +/// Compatibility wrapper over [`replace_squad`]. +/// +/// Kept so the existing Core REST route keeps working, but it no longer has its +/// own write path. That means this route now also validates ownership and +/// rejects duplicate cards — a deliberate tightening, not an accident: those +/// were bugs, and having two write paths with different guarantees is how the +/// stricter one gets bypassed. +pub async fn save_squad( + pool: &Pool, + card_db: &CardDb, + club_id: &str, + req: &SaveSquadRequest, +) -> AppResult { + let replacement = SquadReplacement { + name: req.name.clone(), + formation: req.formation.clone(), + slots: req + .players + .iter() + .map(|p| SlotAssignment { + owned_card_id: p.owned_card_id.clone(), + slot: p.position_index, + is_captain: p.is_captain, + is_on_bench: p.is_on_bench, + }) + .collect(), + }; + let out = replace_squad( + pool, + card_db, + &DefaultSquadRules, + club_id, + req.squad_id.as_deref(), + &replacement, + &ClientReportedEvaluation::default(), + ) + .await?; + Ok(out.squad) } pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> { @@ -278,3 +449,282 @@ pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResu } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::squad::SlotAssignment; + + /// A pool with the real schema, plus two clubs that own one card each. + /// + /// Two clubs specifically: the guarantee under test is that a card + /// belonging to somebody else cannot be placed, and that cannot be + /// expressed with one club. + const TS: &str = "2026-01-01T00:00:00Z"; + + async fn fixture() -> (Pool, CardDb) { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + + for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] { + sqlx::query( + "INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(profile) + .bind(profile) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("profile"); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS) + .execute(&pool).await.expect("club"); + } + // club-a owns card-1 and card-2; club-b owns card-foreign. + for (id, club) in [ + ("card-1", "club-a"), + ("card-2", "club-a"), + ("card-foreign", "club-b"), + ] { + sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)") + .bind(id).bind(club).bind("def-1").bind(TS) + .execute(&pool).await.expect("owned card"); + } + // An empty card database is enough: none of these guarantees consult it. + ( + pool, + CardDb::load("/nonexistent-card-dir").expect("empty card db"), + ) + } + + fn slot(card: &str, n: i64) -> SlotAssignment { + SlotAssignment { + owned_card_id: card.into(), + slot: n, + is_captain: false, + is_on_bench: false, + } + } + + async fn replace( + pool: &Pool, + db: &CardDb, + club: &str, + id: Option<&str>, + slots: Vec, + ) -> AppResult { + replace_squad( + pool, + db, + &DefaultSquadRules, + club, + id, + &SquadReplacement { + name: Some("S".into()), + formation: Some("4-4-2".into()), + slots, + }, + &ClientReportedEvaluation::default(), + ) + .await + } + + async fn slots_of(pool: &Pool, squad_id: &str) -> Vec<(String, i64)> { + sqlx::query_as::<_, (String, i64)>( + "SELECT owned_card_id, position_index FROM squad_players WHERE squad_id = ? ORDER BY position_index", + ).bind(squad_id).fetch_all(pool).await.unwrap() + } + + #[tokio::test] + async fn a_card_owned_by_another_club_cannot_be_placed() { + let (pool, db) = fixture().await; + let err = replace(&pool, &db, "club-a", None, vec![slot("card-foreign", 0)]) + .await + .unwrap_err(); + // Same message as a missing card: whether it exists elsewhere is not + // this caller's business. + assert!( + matches!(err, AppError::NotFound(ref m) if m.contains("card-foreign")), + "{err:?}" + ); + } + + #[tokio::test] + async fn the_same_card_cannot_occupy_two_slots() { + let (pool, db) = fixture().await; + let err = replace( + &pool, + &db, + "club-a", + None, + vec![slot("card-1", 0), slot("card-1", 1)], + ) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::BadRequest(ref m) if m.contains("more than one slot")), + "{err:?}" + ); + } + + #[tokio::test] + async fn two_cards_cannot_occupy_the_same_slot() { + let (pool, db) = fixture().await; + let err = replace( + &pool, + &db, + "club-a", + None, + vec![slot("card-1", 3), slot("card-2", 3)], + ) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::BadRequest(ref m) if m.contains("slot 3")), + "{err:?}" + ); + } + + #[tokio::test] + async fn a_negative_slot_is_refused() { + let (pool, db) = fixture().await; + let err = replace(&pool, &db, "club-a", None, vec![slot("card-1", -1)]) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::BadRequest(ref m) if m.contains("negative")), + "{err:?}" + ); + } + + /// The atomicity guarantee, and the reason this operation exists. + /// + /// The old implementation deleted every squad player before inserting the + /// new ones, outside a transaction. A replacement rejected part-way through + /// therefore destroyed the squad it failed to replace. + #[tokio::test] + async fn a_rejected_replacement_leaves_the_previous_squad_untouched() { + let (pool, db) = fixture().await; + let first = replace( + &pool, + &db, + "club-a", + None, + vec![slot("card-1", 0), slot("card-2", 1)], + ) + .await + .expect("first save"); + let before = slots_of(&pool, &first.squad.id).await; + assert_eq!(before.len(), 2); + + // Valid card in slot 0, then one owned by another club. + let err = replace( + &pool, + &db, + "club-a", + Some(&first.squad.id), + vec![slot("card-1", 0), slot("card-foreign", 1)], + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::NotFound(_)), "{err:?}"); + + assert_eq!( + slots_of(&pool, &first.squad.id).await, + before, + "a rejected replacement must not disturb the stored squad" + ); + } + + /// Replacement means replacement: slots present before and absent from the + /// new assignment must be gone, not merged. + #[tokio::test] + async fn replacement_removes_slots_absent_from_the_new_assignment() { + let (pool, db) = fixture().await; + let first = replace( + &pool, + &db, + "club-a", + None, + vec![slot("card-1", 0), slot("card-2", 1)], + ) + .await + .expect("first"); + let second = replace( + &pool, + &db, + "club-a", + Some(&first.squad.id), + vec![slot("card-2", 5)], + ) + .await + .expect("second"); + assert_eq!(second.slots_written, 1); + assert_eq!( + slots_of(&pool, &first.squad.id).await, + vec![("card-2".to_string(), 5)] + ); + } + + #[tokio::test] + async fn a_squad_belonging_to_another_club_cannot_be_replaced() { + let (pool, db) = fixture().await; + let mine = replace(&pool, &db, "club-a", None, vec![slot("card-1", 0)]) + .await + .expect("mine"); + let err = replace(&pool, &db, "club-b", Some(&mine.squad.id), vec![]) + .await + .unwrap_err(); + assert!(matches!(err, AppError::NotFound(_)), "{err:?}"); + assert_eq!(slots_of(&pool, &mine.squad.id).await.len(), 1); + } + + /// The client's numbers must never become the server's. + #[tokio::test] + async fn client_reported_values_are_reported_as_disagreement_not_stored() { + let (pool, db) = fixture().await; + let out = replace_squad( + &pool, + &db, + &DefaultSquadRules, + "club-a", + None, + &SquadReplacement { + name: Some("S".into()), + formation: Some("4-4-2".into()), + slots: vec![slot("card-1", 0)], + }, + &ClientReportedEvaluation { + client_reported_chemistry: Some(52), + client_reported_rating: Some(99), + client_reported_star_rating: None, + }, + ) + .await + .expect("save"); + + // The card db is empty, so the server derives nothing: 0. + assert_eq!(out.evaluation.chemistry, 0); + assert_eq!(out.evaluation.rating, 0); + // And the disagreement is surfaced rather than reconciled. + let fields: Vec<&str> = out + .client_disagreements + .iter() + .map(|d| d.field.as_str()) + .collect(); + assert_eq!( + fields, + vec!["chemistry", "rating"], + "{:?}", + out.client_disagreements + ); + assert_eq!(out.evaluation.rules, "openfut-default-v2"); + } +} diff --git a/src/services/squad_rules.rs b/src/services/squad_rules.rs new file mode 100644 index 0000000..3cdfba2 --- /dev/null +++ b/src/services/squad_rules.rs @@ -0,0 +1,380 @@ +//! Squad evaluation, behind a game-rules boundary. +//! +//! # Why this is a trait and not a function in `squad.rs` +//! +//! Chemistry, rating and star rating are **game-specific**. FUT chemistry +//! changed substantially between FIFA generations, so a single formula +//! compiled into generic Core would quietly make Core a FIFA-something server. +//! Core is allowed to understand that a squad *has* an evaluation; it is not +//! allowed to know how any particular game computes one. +//! +//! ```text +//! Core owns the squad, slots, items, persistence +//! | and the SEMANTIC concept of an evaluation +//! v +//! SquadRules how a specific game computes it +//! | +//! +-- DefaultSquadRules OpenFUT's own rules (the implementation that +//! | already existed in Core) +//! +-- Fifa17SquadRules NOT YET WRITTEN. The FIFA 17 algorithm is not +//! proven, and inventing one would be worse than +//! having none. +//! ``` +//! +//! # Pure data in, evaluation out +//! +//! Rules take a [`SquadSnapshot`] — already resolved by Core from the database +//! — rather than a pool and a card database. That keeps every rules +//! implementation synchronous, dependency-free and testable without fixtures, +//! and it stops a game's rules from reaching into Core's storage. +//! +//! # Client-reported values are not evaluations +//! +//! FIFA 17 sends its own `chemistry`, `rating` and `starRating` on every squad +//! save. Those are observations about what the client believes, captured for +//! shadow validation, and they are deliberately a *different type* from +//! [`SquadEvaluation`] so no later code can pass one where the other belongs. + +use serde::{Deserialize, Serialize}; + +/// One player in a squad, reduced to the attributes rules are allowed to see. +/// +/// Deliberately not `OwnedCard` + `CardDefinition`: rules should not be able to +/// reach storage identifiers, loan state or acquisition history. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SquadPlayerCard { + /// Core's owned-card id. Present so an evaluation can attribute per-player + /// results; rules must not interpret its contents. + pub owned_card_id: String, + pub card_id: String, + pub name: String, + pub overall: u8, + pub position: String, + pub nation: String, + pub league: String, + pub club: String, + /// Slot this player occupies, in Core's numbering. + pub slot: i64, + pub on_bench: bool, +} + +/// Everything a rules implementation may consider. +#[derive(Debug, Clone, Default)] +pub struct SquadSnapshot { + pub formation: String, + pub players: Vec, +} + +impl SquadSnapshot { + pub fn starters(&self) -> impl Iterator { + self.players.iter().filter(|p| !p.on_bench) + } +} + +/// The semantic result Core understands. +/// +/// `chemistry` has no fixed scale here on purpose — `chemistry_max` travels +/// with it, because a later game may not use 100. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SquadEvaluation { + pub chemistry: i64, + pub chemistry_max: i64, + pub rating: i64, + pub star_rating: i64, + /// Per-player detail, for UIs and for diagnosing a rules mismatch. + pub players: Vec, + /// Which rules produced this, so a stored or logged evaluation is never + /// ambiguous about its own provenance. + pub rules: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlayerEvaluation { + pub owned_card_id: String, + pub chemistry: i64, + pub detail: Vec<(String, i64)>, +} + +/// What a game client claimed about a squad it sent. +/// +/// **Never canonical.** A separate type from [`SquadEvaluation`] specifically so +/// that assigning one to the other does not compile. A modified client can put +/// anything here; OpenFUT has no independent knowledge of what it means until +/// its own rules run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ClientReportedEvaluation { + pub client_reported_chemistry: Option, + pub client_reported_rating: Option, + pub client_reported_star_rating: Option, +} + +/// Result of comparing what the client claimed against what the server derived. +/// +/// A mismatch is **not** silently reconciled in either direction: the server's +/// value stands as canonical and the disagreement is reported so the rules +/// model can be investigated against the exact squad that produced it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvaluationComparison { + pub field: String, + pub client: i64, + pub server: i64, +} + +impl ClientReportedEvaluation { + /// Fields where the client and the server disagree. Empty means agreement + /// on every field the client actually sent. + pub fn compare(&self, server: &SquadEvaluation) -> Vec { + let mut out = Vec::new(); + let mut check = |field: &str, client: Option, srv: i64| { + if let Some(c) = client { + if c != srv { + out.push(EvaluationComparison { + field: field.to_string(), + client: c, + server: srv, + }); + } + } + }; + check( + "chemistry", + self.client_reported_chemistry, + server.chemistry, + ); + check("rating", self.client_reported_rating, server.rating); + check( + "star_rating", + self.client_reported_star_rating, + server.star_rating, + ); + out + } +} + +/// How a specific game evaluates a squad. +pub trait SquadRules: Send + Sync { + /// Stable identifier recorded in [`SquadEvaluation::rules`]. + fn name(&self) -> &'static str; + fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation; +} + +/// OpenFUT's own rules — the implementation that already lived in Core. +/// +/// Moved here unchanged in behaviour rather than rewritten: it is the default +/// for clients that have no game-specific rules, and changing its numbers while +/// relocating it would have made the move unreviewable. +/// +/// Link scoring: club +3 each (max 6), league +1 each (max 4), nation +1 each +/// (max 3), per player capped at 10, team total capped at 100. +pub struct DefaultSquadRules; + +impl SquadRules for DefaultSquadRules { + fn name(&self) -> &'static str { + "openfut-default-v2" + } + + fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation { + let starters: Vec<&SquadPlayerCard> = snapshot.starters().collect(); + + let mut players = Vec::with_capacity(starters.len()); + let mut total: i64 = 0; + + for (i, p) in starters.iter().enumerate() { + let count = |f: fn(&SquadPlayerCard) -> &String, v: &String| { + starters + .iter() + .enumerate() + .filter(|(j, o)| *j != i && f(o) == v) + .count() as i64 + }; + let club_links = count(|c| &c.club, &p.club); + let league_links = count(|c| &c.league, &p.league); + let nation_links = count(|c| &c.nation, &p.nation); + + let club_pts = (club_links * 3).min(6); + let league_pts = league_links.min(4); + let nation_pts = nation_links.min(3); + let chem = (club_pts + league_pts + nation_pts).min(10); + total += chem; + + players.push(PlayerEvaluation { + owned_card_id: p.owned_card_id.clone(), + chemistry: chem, + detail: vec![ + ("club_links".into(), club_links), + ("league_links".into(), league_links), + ("nation_links".into(), nation_links), + ("club_pts".into(), club_pts), + ("league_pts".into(), league_pts), + ("nation_pts".into(), nation_pts), + ], + }); + } + + // Mean overall of the starters, rounded down. Empty squad rates 0 + // rather than dividing by zero. + let rating = if starters.is_empty() { + 0 + } else { + starters.iter().map(|p| p.overall as i64).sum::() / starters.len() as i64 + }; + + SquadEvaluation { + chemistry: total.min(100), + chemistry_max: 100, + rating, + // 0-5 from the rating band. Coarse on purpose: this is OpenFUT's + // own presentation value, not a reconstruction of any game's. + star_rating: match rating { + 0 => 0, + 1..=64 => 1, + 65..=74 => 2, + 75..=81 => 3, + 82..=87 => 4, + _ => 5, + }, + players, + rules: self.name().to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(slot: i64, club: &str, league: &str, nation: &str, overall: u8) -> SquadPlayerCard { + SquadPlayerCard { + owned_card_id: format!("owned-{slot}"), + card_id: format!("card-{slot}"), + name: format!("P{slot}"), + overall, + position: "ST".into(), + nation: nation.into(), + league: league.into(), + club: club.into(), + slot, + on_bench: false, + } + } + + #[test] + fn an_empty_squad_evaluates_without_dividing_by_zero() { + let e = DefaultSquadRules.evaluate(&SquadSnapshot::default()); + assert_eq!(e.chemistry, 0); + assert_eq!(e.rating, 0); + assert_eq!(e.star_rating, 0); + assert!(e.players.is_empty()); + } + + #[test] + fn bench_players_do_not_contribute() { + let mut snap = SquadSnapshot { + formation: "4-4-2".into(), + players: vec![p(0, "A", "L", "N", 80), p(1, "A", "L", "N", 80)], + }; + let with_both = DefaultSquadRules.evaluate(&snap); + snap.players[1].on_bench = true; + let with_bench = DefaultSquadRules.evaluate(&snap); + assert!( + with_bench.chemistry < with_both.chemistry, + "a benched team-mate must not create links: {with_bench:?}" + ); + assert_eq!(with_bench.players.len(), 1); + } + + #[test] + fn links_are_capped_per_category_and_per_player() { + // Eleven identical players: club links alone would be 30 pts uncapped. + let players: Vec<_> = (0..11).map(|i| p(i, "A", "L", "N", 90)).collect(); + let e = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "4-4-2".into(), + players, + }); + for pe in &e.players { + assert_eq!(pe.chemistry, 10, "per-player cap is 10: {pe:?}"); + } + assert_eq!(e.chemistry, 100); + assert_eq!(e.chemistry_max, 100); + } + + #[test] + fn team_chemistry_is_capped_at_the_maximum() { + // 15 starters would total 150 uncapped. + let players: Vec<_> = (0..15).map(|i| p(i, "A", "L", "N", 90)).collect(); + let e = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "x".into(), + players, + }); + assert_eq!(e.chemistry, 100); + } + + #[test] + fn unrelated_players_earn_no_chemistry() { + let players = vec![ + p(0, "A", "L1", "N1", 80), + p(1, "B", "L2", "N2", 80), + p(2, "C", "L3", "N3", 80), + ]; + let e = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "x".into(), + players, + }); + assert_eq!(e.chemistry, 0); + assert_eq!(e.rating, 80); + } + + #[test] + fn the_evaluation_names_the_rules_that_produced_it() { + let e = DefaultSquadRules.evaluate(&SquadSnapshot::default()); + assert_eq!(e.rules, "openfut-default-v2"); + assert_eq!(DefaultSquadRules.name(), "openfut-default-v2"); + } + + /// The comparison must report disagreement rather than reconcile it. + #[test] + fn a_client_that_disagrees_is_reported_not_reconciled() { + let server = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "x".into(), + players: vec![p(0, "A", "L", "N", 80)], + }); + let claimed = ClientReportedEvaluation { + client_reported_chemistry: Some(52), + client_reported_rating: Some(server.rating), + client_reported_star_rating: None, + }; + let diff = claimed.compare(&server); + assert_eq!(diff.len(), 1, "{diff:?}"); + assert_eq!(diff[0].field, "chemistry"); + assert_eq!(diff[0].client, 52); + assert_eq!(diff[0].server, server.chemistry); + // And the server's own value is untouched by the comparison. + assert_eq!(server.chemistry, 0); + } + + /// A field the client did not send cannot disagree. + #[test] + fn absent_client_fields_are_not_treated_as_zero() { + let server = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "x".into(), + players: vec![p(0, "A", "L", "N", 80)], + }); + assert!(ClientReportedEvaluation::default() + .compare(&server) + .is_empty()); + } + + #[test] + fn agreement_reports_nothing() { + let server = DefaultSquadRules.evaluate(&SquadSnapshot { + formation: "x".into(), + players: vec![p(0, "A", "L", "N", 80)], + }); + let claimed = ClientReportedEvaluation { + client_reported_chemistry: Some(server.chemistry), + client_reported_rating: Some(server.rating), + client_reported_star_rating: Some(server.star_rating), + }; + assert!(claimed.compare(&server).is_empty()); + } +}