2 Commits

Author SHA1 Message Date
funman300 33b5ac1908 feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.

Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:22:57 +00:00
funman300 eab522a1eb squad: transactional replace_squad + a game-rules boundary for evaluation
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".

replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.

replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:

  - card must exist AND belong to this club
  - a card may occupy at most one slot
  - a slot may hold at most one card
  - slot indices must not be negative
  - the squad being replaced must belong to this club

save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.

A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.

Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.

It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.

Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.

Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".

Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:56:33 +00:00
10 changed files with 1658 additions and 179 deletions
+27
View File
@@ -13,6 +13,33 @@ pub enum Rarity {
Icon,
}
/// Visual card quality tier (gold/silver/bronze).
///
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quality {
Bronze,
Silver,
Gold,
}
impl Quality {
/// Classify a base overall rating into its quality tier.
pub fn from_overall(overall: u8) -> Self {
if overall >= 75 {
Quality::Gold
} else if overall >= 65 {
Quality::Silver
} else {
Quality::Bronze
}
}
}
/// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition {
+43
View File
@@ -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<String>,
pub formation: Option<String>,
pub slots: Vec<SlotAssignment>,
}
#[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<crate::services::squad_rules::EvaluationComparison>,
}
+31 -8
View File
@@ -9,7 +9,11 @@ use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
services::{club as club_svc, profile as profile_svc},
services::{
club as club_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
};
/// Quick-sell value for a card based on overall rating.
@@ -93,7 +97,10 @@ pub async fn get_cards(
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
}
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
pub async fn get_collection(
State(state): State<AppState>,
Query(query): Query<OwnedItemQuery>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -104,14 +111,14 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
.fetch_all(&state.pool)
.await?;
let with_defs: Vec<Value> = owned
let views: Vec<OwnedItemView> = owned
.iter()
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
json!({
let body = json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
@@ -122,14 +129,30 @@ pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Val
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
})
});
OwnedItemView {
owned_card_id: o.id.clone(),
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
}
})
})
.collect();
Ok(Json(
json!({ "collection": with_defs, "total": with_defs.len() }),
))
let page = inventory::apply_query(views, &query);
let returned = page.items.len();
Ok(Json(json!({
"collection": page.items,
"total": page.total,
"returned": returned,
"offset": page.offset,
"limit": page.limit,
})))
}
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
+1 -1
View File
@@ -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 })))
}
-115
View File
@@ -1,115 +0,0 @@
use crate::models::card::CardDefinition;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChemistryResult {
/// Sum of all player chemistry values, max 33 (11 × 3).
pub total: u8,
/// Per-player chemistry in the same order as the input slice (13 each).
pub per_player: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq)]
enum LinkStrength {
/// Same club AND same nationality.
Full,
/// Same league OR same nationality (but not Full).
Half,
None,
}
fn link_strength(a: &CardDefinition, b: &CardDefinition) -> LinkStrength {
if a.club == b.club && a.nation == b.nation {
return LinkStrength::Full;
}
if a.league == b.league || a.nation == b.nation {
return LinkStrength::Half;
}
LinkStrength::None
}
/// Compute team chemistry for a squad given the resolved `CardDefinition`s.
///
/// Each player starts at 1 chemistry. Full links add 2 to both players;
/// half links add 1. Each player is clamped to [1, 3].
/// Squad chemistry is the sum of all player chemistry values (max 33).
pub fn calculate_chemistry(cards: &[CardDefinition]) -> ChemistryResult {
let n = cards.len();
let mut per_player: Vec<i16> = vec![1; n];
for i in 0..n {
for j in (i + 1)..n {
let bonus = match link_strength(&cards[i], &cards[j]) {
LinkStrength::Full => 2,
LinkStrength::Half => 1,
LinkStrength::None => 0,
};
if bonus > 0 {
per_player[i] += bonus;
per_player[j] += bonus;
}
}
}
let per_player: Vec<u8> = per_player
.into_iter()
.map(|c| c.clamp(1, 3) as u8)
.collect();
let total = per_player.iter().map(|&c| c as u16).sum::<u16>() as u8;
ChemistryResult { total, per_player }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::card::Rarity;
fn card(club: &str, league: &str, nation: &str) -> CardDefinition {
CardDefinition {
id: "x".into(),
name: "Player".into(),
overall: 75,
position: "CM".into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
pace: 75,
shooting: 75,
passing: 75,
dribbling: 75,
defending: 75,
physical: 75,
rarity: Rarity::Gold,
image_path: None,
}
}
#[test]
fn all_same_club_and_nationality_max_chem() {
let cards: Vec<_> = (0..11).map(|_| card("FC Test", "LaLiga", "ESP")).collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 33);
assert!(result.per_player.iter().all(|&c| c == 3));
}
#[test]
fn all_different_attributes_min_chem() {
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), &format!("League{i}"), &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
assert_eq!(result.total, 11);
assert!(result.per_player.iter().all(|&c| c == 1));
}
#[test]
fn same_league_gives_half_links() {
// All same league but different club and nation → half links only.
let cards: Vec<_> = (0..11)
.map(|i| card(&format!("Club{i}"), "PremierLeague", &format!("N{i}")))
.collect();
let result = calculate_chemistry(&cards);
// Each player has half links to all 10 others → +10, clamped to 3.
assert!(result.per_player.iter().all(|&c| c == 3));
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Game-independent owned-inventory query: semantic filtering, deterministic
//! ordering, and offset/limit pagination over a club's owned items.
//!
//! This layer is deliberately free of any game-specific concepts. It never sees
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
//! responsible for translating its wire query into the *semantic* values here
//! (quality tier, entity **names**, semantic offset/limit). The canonical
//! ordering is imposed by Core so pagination is correct and repeatable
//! regardless of what (if any) sort the client requests; see the module tests
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
//!
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
//! before filtering is the production bug this replaces (a client that pages an
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
use serde::Deserialize;
use crate::models::card::Quality;
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
/// optional; combined filters are ANDed. Absent field = no constraint.
#[derive(Debug, Default, Deserialize)]
pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
/// Nation name, e.g. "Argentina" (matched case-insensitively).
#[serde(default)]
pub nation: Option<String>,
/// League name, e.g. "Premier League" (matched case-insensitively).
#[serde(default)]
pub league: Option<String>,
/// Club name, e.g. "Chelsea" (matched case-insensitively).
#[serde(default)]
pub club: Option<String>,
/// Number of leading items to skip after filtering + ordering.
#[serde(default)]
pub offset: Option<i64>,
/// Maximum number of items to return in the page.
#[serde(default)]
pub limit: Option<i64>,
}
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
pub struct OwnedItemView {
pub owned_card_id: String,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
pub effective_overall: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub body: serde_json::Value,
}
impl OwnedItemView {
fn quality(&self) -> Quality {
Quality::from_overall(self.base_overall)
}
}
/// Result of applying a query: the requested page plus the count of items that
/// matched the filter **before** pagination (what a client needs to page).
pub struct QueryPage {
pub items: Vec<serde_json::Value>,
pub total: usize,
pub offset: usize,
pub limit: Option<usize>,
}
/// Does an item satisfy every present filter (AND semantics)?
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
let pos_ok = q
.position
.as_ref()
.map(|p| item.position.eq_ignore_ascii_case(p))
.unwrap_or(true);
let nation_ok = q
.nation
.as_ref()
.map(|n| item.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = q
.league
.as_ref()
.map(|l| item.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = q
.club
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
///
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
/// non-negative values (the wire never sends negatives; clamping keeps a
/// malformed request from panicking).
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
// 1. filter
items.retain(|it| matches(it, q));
let total = items.len();
// 2. deterministic total order (independent of input/DB order)
items.sort_by(|a, b| {
b.effective_overall
.cmp(&a.effective_overall)
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
});
// 3. paginate
let offset = q.offset.unwrap_or(0).max(0) as usize;
let limit = q.limit.map(|l| l.max(0) as usize);
let page: Vec<serde_json::Value> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.map(|it| it.body)
.collect();
QueryPage {
items: page,
total,
offset,
limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view(
id: &str,
overall: u8,
position: &str,
nation: &str,
league: &str,
club: &str,
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
body: json!({ "owned_card_id": id, "overall": overall }),
}
}
fn ids(page: &QueryPage) -> Vec<String> {
page.items
.iter()
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
.collect()
}
fn fixture() -> Vec<OwnedItemView> {
vec![
view("a", 84, "ST", "England", "Premier League", "Northgate"),
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
]
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
assert_eq!(p.total, 5);
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
}
#[test]
fn quality_gold_selects_overall_75_plus() {
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["e", "b", "a"]);
}
#[test]
fn filters_are_anded() {
let q = OwnedItemQuery {
league: Some("Premier League".into()),
position: Some("ST".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
}
#[test]
fn case_insensitive_name_match() {
let q = OwnedItemQuery {
club: Some("chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn empty_when_nothing_matches() {
let q = OwnedItemQuery {
nation: Some("Argentina".into()),
club: Some("Chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 0);
assert!(p.items.is_empty());
}
#[test]
fn filter_runs_before_pagination() {
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
// If pagination ran first, offset/limit would slice the full 5-item set.
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
offset: Some(1),
limit: Some(1),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn pages_do_not_overlap_and_advance() {
let page = |off| {
apply_query(
fixture(),
&OwnedItemQuery {
offset: Some(off),
limit: Some(2),
..Default::default()
},
)
};
let p0 = page(0);
let p1 = page(2);
assert_eq!(ids(&p0), ["e", "b"]);
assert_eq!(ids(&p1), ["a", "c"]);
// start advancing must NOT re-serve page one
assert_ne!(ids(&p0), ids(&p1));
assert_eq!(p0.total, 5);
assert_eq!(p1.total, 5);
}
#[test]
fn offset_past_end_is_empty_not_wrapped() {
let q = OwnedItemQuery {
offset: Some(100),
limit: Some(11),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert!(p.items.is_empty());
assert_eq!(p.total, 5);
}
#[test]
fn ordering_is_stable_on_overall_ties() {
let items = vec![
view("z", 80, "ST", "N", "L", "C"),
view("a", 80, "ST", "N", "L", "C"),
view("m", 80, "ST", "N", "L", "C"),
];
let p = apply_query(items, &OwnedItemQuery::default());
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod notification;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod inventory;
pub mod season;
pub mod market;
pub mod match_service;
@@ -15,5 +16,6 @@ pub mod profile;
pub mod sbc;
pub mod settings;
pub mod squad;
pub mod squad_rules;
pub mod statistics;
pub mod upgrades;
+505 -55
View File
@@ -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<SquadPlayer>)> {
@@ -191,68 +200,153 @@ pub async fn calculate_chemistry(
}))
}
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
/// 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<SquadReplaced> {
// ── validate before touching anything ────────────────────────────────
let mut seen: HashSet<&str> = HashSet::new();
let mut slots_seen: HashSet<i64> = 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<Squad> {
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<SlotAssignment>,
) -> AppResult<SquadReplaced> {
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");
}
}
+380
View File
@@ -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<SquadPlayerCard>,
}
impl SquadSnapshot {
pub fn starters(&self) -> impl Iterator<Item = &SquadPlayerCard> {
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<PlayerEvaluation>,
/// 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<i64>,
pub client_reported_rating: Option<i64>,
pub client_reported_star_rating: Option<i64>,
}
/// 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<EvaluationComparison> {
let mut out = Vec::new();
let mut check = |field: &str, client: Option<i64>, 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::<i64>() / 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());
}
}
+378
View File
@@ -1897,3 +1897,381 @@ async fn test_trade_history_empty_initially() {
assert!(json["trades"].is_array());
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
}
// ── Owned-item query: filtering, deterministic order, pagination ──────────────
//
// Regression coverage for the FIFA17 "My Squad" owned-player search. Retail
// evidence (sha256-identical response bodies) proved the Python oracle applies
// ONLY league+team and ignores level/rare/position/nation/start/count, which
// re-serves page one forever and amplifies requests. Core intentionally fixes
// this: filter (AND) -> deterministic order -> paginate. Semantics only — no raw
// FIFA ids reach Core (the adapter resolves ids to the names asserted here).
/// Build an app AND keep the pool, so tests can seed a deterministic inventory.
async fn build_test_app_with_pool() -> (axum::Router, sqlx::SqlitePool) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
let app = openfut_core::build_app(pool.clone(), "data")
.await
.expect("app build");
(app, pool)
}
/// A deliberately cluttered but KNOWN owned inventory drawn from committed card
/// data. Spans qualities (gold/silver/bronze), several leagues/nations/positions
/// and includes irrelevant "junk" that must disappear under a filter. Ids are
/// `oc_NN` in listed order so the `(overall desc, owned_id asc)` order is fixed.
const CLUTTERED_FIXTURE: &[(&str, &str)] = &[
("oc_00", "card_raregold_008"), // 86 CDM Ghana / Premier League / Chelsea
("oc_01", "card_hero_004"), // 85 CDM Nigeria / Premier League / Chelsea
("oc_02", "card_raregold_010"), // 87 LB Russia / Premier League / Arsenal
("oc_03", "card_pl_001"), // 84 ST England / Premier League / Northgate
("oc_04", "card_ll_008"), // 82 ST Argentina / La Liga / Valencia Azul
("oc_05", "card_raregold_004"), // 89 LW Argentina / Primera Division / Boca
("oc_06", "card_totw_004"), // 92 LW Argentina / Primera Division / Boca
("oc_07", "card_silver_001"), // 72 ST Brazil / Brasileirao / Athletico
("oc_08", "card_silver_002"), // 70 CM Italy / Serie B / Frosinone
("oc_09", "card_bronze_001"), // 62 ST Brazil / Serie B / Santos
("oc_10", "card_bronze_002"), // 60 CM Italy / Serie C / Modena
("oc_11", "card_raregold_001"), // 88 ST Brazil / Brasileirao / Flamengo
("oc_12", "card_raregold_002"), // 86 CAM Italy / Serie A / AS Roma
("oc_13", "card_raregold_003"), // 87 CB Germany / Bundesliga / Bayer
];
async fn seed_cluttered(app: &axum::Router, pool: &sqlx::SqlitePool) {
auth(app, "ClutterClub").await;
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
.fetch_one(pool)
.await
.expect("club exists after auth");
// Replace the auto-granted starter pack with the deterministic fixture.
sqlx::query("DELETE FROM owned_cards WHERE club_id = ?")
.bind(&club_id)
.execute(pool)
.await
.unwrap();
for (oc_id, card_id) in CLUTTERED_FIXTURE {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus) \
VALUES (?, ?, ?, 0, NULL, '2026-01-01T00:00:00Z', 'basic', NULL, 0)",
)
.bind(oc_id)
.bind(&club_id)
.bind(card_id)
.execute(pool)
.await
.unwrap();
}
}
fn coll_card_ids(v: &Value) -> Vec<String> {
v["collection"]
.as_array()
.unwrap()
.iter()
.map(|e| e["card"]["id"].as_str().unwrap().to_string())
.collect()
}
fn sorted(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
}
#[tokio::test]
async fn test_owned_query_no_filter_returns_all() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["total"], 14);
assert_eq!(j["returned"], 14);
assert_eq!(coll_card_ids(&j).len(), 14);
}
#[tokio::test]
async fn test_owned_query_quality_gold() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?quality=gold").await;
assert_eq!(j["total"], 10);
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
vec![
"card_raregold_008",
"card_hero_004",
"card_raregold_010",
"card_pl_001",
"card_ll_008",
"card_raregold_004",
"card_totw_004",
"card_raregold_001",
"card_raregold_002",
"card_raregold_003",
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?position=ST").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
[
"card_pl_001",
"card_ll_008",
"card_silver_001",
"card_bronze_001",
"card_raregold_001"
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_nation() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?nation=Argentina").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_ll_008", "card_raregold_004", "card_totw_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
[
"card_raregold_008",
"card_hero_004",
"card_raregold_010",
"card_pl_001"
]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_club() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?club=Chelsea").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_raregold_008", "card_hero_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league_and_club() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League&club=Chelsea").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_raregold_008", "card_hero_004"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_league_and_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
assert_eq!(j["total"], 1);
assert_eq!(coll_card_ids(&j), ["card_pl_001"]);
}
#[tokio::test]
async fn test_owned_query_quality_and_position() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection?quality=gold&position=ST").await;
assert_eq!(
sorted(coll_card_ids(&j)),
sorted(
["card_pl_001", "card_ll_008", "card_raregold_001"]
.into_iter()
.map(String::from)
.collect()
)
);
}
#[tokio::test]
async fn test_owned_query_no_results() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (s, j) = json_get(&app, "/collection?nation=Argentina&club=Chelsea").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(j["total"], 0);
assert!(coll_card_ids(&j).is_empty());
}
#[tokio::test]
async fn test_owned_query_deterministic_order_overall_desc() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, j) = json_get(&app, "/collection").await;
let overalls: Vec<i64> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|e| e["effective_overall"].as_i64().unwrap())
.collect();
let mut sorted_desc = overalls.clone();
sorted_desc.sort_by(|a, b| b.cmp(a));
assert_eq!(
overalls, sorted_desc,
"collection must be overall-descending"
);
assert_eq!(
coll_card_ids(&j)[0],
"card_totw_004",
"highest overall (92) first"
);
}
#[tokio::test]
async fn test_owned_query_offset_and_limit() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
// gold set ordered: totw_004, raregold_004, raregold_001, raregold_010, ...
let (_, j) = json_get(&app, "/collection?quality=gold&limit=3").await;
assert_eq!(
j["total"], 10,
"total is the filtered count, not the page size"
);
assert_eq!(j["returned"], 3);
assert_eq!(
coll_card_ids(&j),
["card_totw_004", "card_raregold_004", "card_raregold_001"]
);
let (_, j2) = json_get(&app, "/collection?quality=gold&offset=3&limit=3").await;
assert_eq!(j2["total"], 10);
assert_eq!(
coll_card_ids(&j2)[0],
"card_raregold_010",
"offset advances past page one"
);
}
#[tokio::test]
async fn test_owned_query_pagination_no_repeated_first_page() {
// THE production regression: paging must advance and never re-serve page one,
// with filters retained on every page. A mutation that ignores `offset` (the
// Python bug) makes every page identical and fails here.
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let page = |off: u32| {
let app = app.clone();
async move {
let (_, j) = json_get(
&app,
&format!("/collection?quality=gold&limit=4&start_ignored=0&offset={off}"),
)
.await;
j
}
};
let p0 = page(0).await;
let p1 = page(4).await;
let p2 = page(8).await;
let ids0 = coll_card_ids(&p0);
let ids1 = coll_card_ids(&p1);
let ids2 = coll_card_ids(&p2);
// sizes: 4, 4, 2 over the 10-item gold set
assert_eq!(ids0.len(), 4);
assert_eq!(ids1.len(), 4);
assert_eq!(ids2.len(), 2);
// page one is NOT repeated on later pages
assert_ne!(ids0, ids1, "offset advance must not re-serve page one");
assert_ne!(ids0, ids2);
// pairwise disjoint (no duplicates across pages)
for a in &ids0 {
assert!(
!ids1.contains(a) && !ids2.contains(a),
"pages overlap on {a}"
);
}
for a in &ids1 {
assert!(!ids2.contains(a), "pages overlap on {a}");
}
// union == the full filtered set, each page still all-gold, no dupes
let mut union: Vec<String> = ids0.iter().chain(&ids1).chain(&ids2).cloned().collect();
let count = union.len();
union.sort();
union.dedup();
assert_eq!(union.len(), count, "no duplicate items across pages");
assert_eq!(union.len(), 10, "pages cover the whole filtered set");
// filter retained across pages: every id on every page is a gold card
let (_, all_gold) = json_get(&app, "/collection?quality=gold").await;
let gold_set = sorted(coll_card_ids(&all_gold));
assert_eq!(sorted(union), gold_set);
// total constant across pages
assert_eq!(p0["total"], 10);
assert_eq!(p1["total"], 10);
assert_eq!(p2["total"], 10);
}
#[tokio::test]
async fn test_owned_query_parameter_order_invariance() {
let (app, pool) = build_test_app_with_pool().await;
seed_cluttered(&app, &pool).await;
let (_, a) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
let (_, b) = json_get(&app, "/collection?position=ST&league=Premier%20League").await;
assert_eq!(
coll_card_ids(&a),
coll_card_ids(&b),
"HTTP param order must not change the result"
);
assert_eq!(a["total"], b["total"]);
}