1 Commits

Author SHA1 Message Date
funman300 8e280de99e feat: add chemistry calculation engine scaffold
CI / Build, lint & test (pull_request) Failing after 1m23s
Adds src/services/chemistry.rs with calculate_chemistry() that scores
club/league/nation links between players (1–3 per player, max 33 total).
Includes passing unit tests for all-same and all-different squads.

Closes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:10:40 -07:00
10 changed files with 181 additions and 1660 deletions
-27
View File
@@ -13,33 +13,6 @@ 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,46 +54,3 @@ 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>,
}
+8 -31
View File
@@ -9,11 +9,7 @@ use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
services::{
club as club_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
services::{club as club_svc, profile as profile_svc},
};
/// Quick-sell value for a card based on overall rating.
@@ -97,10 +93,7 @@ pub async fn get_cards(
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
}
pub async fn get_collection(
State(state): State<AppState>,
Query(query): Query<OwnedItemQuery>,
) -> AppResult<Json<Value>> {
pub async fn get_collection(State(state): State<AppState>) -> 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?;
@@ -111,14 +104,14 @@ pub async fn get_collection(
.fetch_all(&state.pool)
.await?;
let views: Vec<OwnedItemView> = owned
let with_defs: Vec<Value> = 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);
let body = json!({
json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
@@ -129,30 +122,14 @@ pub async fn get_collection(
"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();
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,
})))
Ok(Json(
json!({ "collection": with_defs, "total": with_defs.len() }),
))
}
/// 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, &state.card_db, &club.id, &req).await?;
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
Ok(Json(json!({ "squad": squad })))
}
+115
View File
@@ -0,0 +1,115 @@
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
@@ -1,291 +0,0 @@
//! 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,7 +6,6 @@ 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;
@@ -16,6 +15,5 @@ pub mod profile;
pub mod sbc;
pub mod settings;
pub mod squad;
pub mod squad_rules;
pub mod statistics;
pub mod upgrades;
+57 -507
View File
@@ -3,19 +3,10 @@ use crate::{
error::{AppError, AppResult},
models::{
card::{CardDefinition, OwnedCard},
squad::{
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
SquadReplacement,
},
},
services::{
card_db::CardDb,
squad_rules::{
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
},
squad::{SaveSquadRequest, Squad, SquadPlayer, SquadPlayerInput},
},
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>)> {
@@ -200,153 +191,68 @@ pub async fn calculate_chemistry(
}))
}
/// 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 ──────────────────────────────────
pub async fn save_squad(pool: &Pool, club_id: &str, req: &SaveSquadRequest) -> AppResult<Squad> {
let now = chrono::Utc::now().to_rfc3339();
let mut tx = pool.begin().await?;
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")))?;
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")))?;
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(&mut *tx)
.await?;
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
}
};
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&squad_id)
.execute(&mut *tx)
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?;
for (slot, owned) in &resolved {
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&verified)
.execute(pool)
.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
};
for player in &req.players {
let sp_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&sp_id)
.bind(&squad_id)
.bind(&owned.id)
.bind(slot.slot)
.bind(slot.is_captain)
.bind(slot.is_on_bench)
.execute(&mut *tx)
.bind(&player.owned_card_id)
.bind(player.position_index)
.bind(player.is_captain)
.bind(player.is_on_bench)
.execute(pool)
.await?;
}
@@ -354,87 +260,10 @@ pub async fn replace_squad(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
)
.bind(&squad_id)
.fetch_one(&mut *tx)
.fetch_one(pool)
.await?;
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)
Ok(squad)
}
pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> {
@@ -449,282 +278,3 @@ 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
@@ -1,380 +0,0 @@
//! 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,381 +1897,3 @@ 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"]);
}