feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s

Core could only own players. Everything else a FUT club holds — managers, staff,
consumables, kits, badges, balls, stadiums — had no representation, so the only
way to show one to a client was to synthesise it on read. That is the failure
mode this commit exists to make impossible: read authority, write authority and
persistent ownership authority are now the same rows.

MODEL. There is deliberately NO parallel items table. A manager, a consumable, a
kit and a player are all rows in `owned_cards`, differing only by a new
game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball|
stadium|misc). A game adapter translates its own taxonomy — FIFA 17's
`cardsubtypeid` and resource ranges — into one of those tokens before ownership
reaches Core; no game's numerics land here. Ownership stays INSTANCE-based:
`card_id` is the definition, `id` is the instance, and two copies of one
definition remain two rows.

`quantity` is a nullable per-instance attribute, not a replacement for the
instance. The real profile settles this: its 17 consumables are instance-based
and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of
definition 5003068 exist as two distinct instances. So NULL means "not a stack"
and a positive integer is the stack size; collapsing instances into counts is
forbidden by the model.

ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes
`club_active_items` over the five slots that correspond exactly to the client's
recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101,
activeAwayKit 102, activeBall 103, activeStadium 104). There is no
activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The
invariants are schema-enforced rather than conventional: PK(club_id, slot) allows
at most one item per role, `owned_card_id UNIQUE` makes "the same card is both
home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or
consumed item cannot be projected back as active. 0024's trigger is preserved in
semantics — and dropped EXPLICITLY before its table, because it lives ON
`owned_cards`, so DROP TABLE would have orphaned it and broken every later
ownership transfer. It still exists because the market moves ownership by UPDATE,
which no foreign key can observe.

CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive —
validate source ownership and kind, validate target, mutate, consume the source
exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in
migration 0027, the same discipline as `match_completions`. It supports both
deleting the row and decrementing a stack, chosen by the caller, inside the one
transaction and the one replay guard. It deliberately contains NO category
formulas: an unreversed effect must not be invented, so callers supply the
mutation and category validation stays explicit.

`/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection`
now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and
— importantly — stops dropping an owned card with a missing definition silently:
the envelope reports `owned_rows`, `unresolved_items` and the offending
definition ids. That silent `filter_map` is the documented cause of a club that
looks empty while the rows are all present.

Verified against a REAL populated club, not a fixture: the production snapshot
(migration 19) is copied to a tempdir, migrated to 0024, given two kit
designations on real owned instances, then migrated to head. 1986 owned rows
survive as content_kind='player', both designations land in `club_active_items`,
no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
This commit is contained in:
funman300
2026-08-21 19:10:54 +00:00
parent bae0a2bdaa
commit 8b1081019f
20 changed files with 2513 additions and 248 deletions
+297 -136
View File
@@ -1,7 +1,10 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::{card::OwnedCard, club::Club},
models::{
card::{ActiveSlot, ContentKind, OwnedCard, OWNED_CARD_SELECT},
club::Club,
},
};
use chrono::Utc;
@@ -134,9 +137,6 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
// meaning of "manager" (itemType/contract/chemistry), never Core.
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
/// The club's most-recently-updated squad id (its "active" squad), matching the
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
@@ -166,7 +166,8 @@ pub async fn get_squad_manager_for_squad(
club_id: &str,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
"{OWNED_CARD_SELECT} \
WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
AND club_id = ?"
))
.bind(squad_id)
@@ -240,94 +241,137 @@ pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
Ok(())
}
// ───────────────────────── active club kits ────────────────────────────────
// ─────────────────────── active club item designations ──────────────────────
//
// Generic, ownership-backed club state (migration 0026 `club_active_items`):
// which owned INSTANCE currently occupies each club-scoped role (home/away kit,
// badge, ball, stadium). Ownership itself never lives here — a designation is a
// pointer into `owned_cards`, revalidated against current ownership on every
// read, so a stale row can never project an item the club does not own.
//
// Core enforces the generic invariants (ownership, one instance per slot, slot
// admits exactly one `ContentKind`); a game adapter maps its own taxonomy onto
// `ContentKind` before it gets here.
/// The ownership-backed home and away kit assignments for one club.
/// Every active club-item designation, keyed by slot.
///
/// Slots with no designation are simply absent. Held as a `Vec` rather than a
/// map so the projection order is the canonical [`ActiveSlot::ALL`] order.
#[derive(Debug, Clone, Default)]
pub struct ActiveClubKits {
pub home: Option<OwnedCard>,
pub away: Option<OwnedCard>,
pub struct ActiveClubItems {
pub items: Vec<(ActiveSlot, OwnedCard)>,
}
async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult<Option<OwnedCard>> {
impl ActiveClubItems {
/// The owned instance occupying `slot`, if any.
pub fn get(&self, slot: ActiveSlot) -> Option<&OwnedCard> {
self.items
.iter()
.find(|(s, _)| *s == slot)
.map(|(_, card)| card)
}
}
/// Read one slot's designation, revalidated against current club ownership.
async fn get_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_SELECT} WHERE id = ( \
SELECT owned_card_id FROM club_kit_assignments WHERE club_id = ? AND slot = ? \
"{OWNED_CARD_SELECT} WHERE id = ( \
SELECT owned_card_id FROM club_active_items WHERE club_id = ? AND slot = ? \
) AND club_id = ?"
))
.bind(club_id)
.bind(slot)
.bind(slot.as_str())
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Read both active kit roles. Each assignment is revalidated against current
/// Read every active club-item designation. Each is revalidated against current
/// ownership, so a stale/corrupt row never surfaces another club's item.
pub async fn get_active_club_kits(pool: &Pool, club_id: &str) -> AppResult<ActiveClubKits> {
Ok(ActiveClubKits {
home: get_club_kit_slot(pool, club_id, "home").await?,
away: get_club_kit_slot(pool, club_id, "away").await?,
})
}
/// Atomically replace both active kit roles. Core enforces generic ownership and
/// distinct-instance invariants; the game adapter validates that each definition
/// is a kit before asking Core to assign it.
pub async fn set_active_club_kits(
pool: &Pool,
club_id: &str,
home_owned_card_id: Option<&str>,
away_owned_card_id: Option<&str>,
) -> AppResult<()> {
if home_owned_card_id.is_some() && home_owned_card_id == away_owned_card_id {
return Err(AppError::BadRequest(
"home and away kits must be different owned items".into(),
));
}
let mut tx = pool.begin().await?;
for owned_card_id in [home_owned_card_id, away_owned_card_id]
.into_iter()
.flatten()
{
let owned = sqlx::query_scalar::<_, String>(
"SELECT id FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?;
if owned.is_none() {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
pub async fn get_active_club_items(pool: &Pool, club_id: &str) -> AppResult<ActiveClubItems> {
let mut items = Vec::new();
for slot in ActiveSlot::ALL {
if let Some(card) = get_active_club_item(pool, club_id, slot).await? {
items.push((slot, card));
}
}
Ok(ActiveClubItems { items })
}
sqlx::query("DELETE FROM club_kit_assignments WHERE club_id = ?")
.bind(club_id)
/// Designate `owned_card_id` as `club_id`'s active item for `slot`, replacing any
/// existing designation for that slot.
///
/// Fail-closed, in one transaction:
/// * the instance MUST be owned by `club_id` (so a client cannot install
/// another club's item, nor an id that does not exist);
/// * its `content_kind` MUST be the kind the slot admits (a badge in
/// `home_kit` is rejected, not silently accepted);
/// * an instance already designated for a DIFFERENT slot is released first, so
/// the `owned_card_id UNIQUE` invariant is upheld by an explicit move rather
/// than a constraint error.
pub async fn set_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
owned_card_id: &str,
) -> AppResult<()> {
let mut tx = pool.begin().await?;
let owned = sqlx::query_as::<_, (String, ContentKind)>(
"SELECT id, content_kind FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?;
let Some((_, kind)) = owned else {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
};
let required = slot.required_kind();
if kind != required {
return Err(AppError::BadRequest(format!(
"slot '{slot}' requires content kind '{required}', but owned card \
'{owned_card_id}' is '{kind}'"
)));
}
// Release this instance from any other slot, then take the target slot.
sqlx::query("DELETE FROM club_active_items WHERE owned_card_id = ?")
.bind(owned_card_id)
.execute(&mut *tx)
.await?;
let now = Utc::now().to_rfc3339();
for (slot, owned_card_id) in [("home", home_owned_card_id), ("away", away_owned_card_id)] {
if let Some(owned_card_id) = owned_card_id {
sqlx::query(
"INSERT INTO club_kit_assignments \
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot)
.bind(owned_card_id)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
sqlx::query(
"INSERT OR REPLACE INTO club_active_items \
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot.as_str())
.bind(owned_card_id)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Clear `club_id`'s designation for `slot` (idempotent — an already-empty slot
/// is a successful no-op).
pub async fn clear_active_club_item(pool: &Pool, club_id: &str, slot: ActiveSlot) -> AppResult<()> {
sqlx::query("DELETE FROM club_active_items WHERE club_id = ? AND slot = ?")
.bind(club_id)
.bind(slot.as_str())
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -357,18 +401,30 @@ mod tests {
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
for (id, club, definition) in [
("mgr", "club-a", "def-mgr"),
("mgr2", "club-a", "def-mgr"),
("player", "club-a", "def-player"),
("kit-home", "club-a", "def-kit-home"),
("kit-away", "club-a", "def-kit-away"),
("kit-away-2", "club-a", "def-kit-away-2"),
("foreign", "club-b", "def-kit-foreign"),
for (id, club, definition, kind) in [
("mgr", "club-a", "def-mgr", ContentKind::Manager),
("mgr2", "club-a", "def-mgr", ContentKind::Manager),
("player", "club-a", "def-player", ContentKind::Player),
("kit-home", "club-a", "def-kit-home", ContentKind::Kit),
("kit-away", "club-a", "def-kit-away", ContentKind::Kit),
("kit-away-2", "club-a", "def-kit-away-2", ContentKind::Kit),
("badge", "club-a", "def-badge", ContentKind::Badge),
("ball", "club-a", "def-ball", ContentKind::Ball),
("stadium", "club-a", "def-stadium", ContentKind::Stadium),
("foreign", "club-b", "def-kit-foreign", ContentKind::Kit),
] {
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind(id).bind(club).bind(definition).bind(TS)
.execute(&pool).await.expect("owned card");
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
VALUES (?, ?, ?, 0, ?, ?)",
)
.bind(id)
.bind(club)
.bind(definition)
.bind(TS)
.bind(kind.as_str())
.execute(&pool)
.await
.expect("owned card");
}
// club-a has one squad.
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
@@ -383,8 +439,8 @@ mod tests {
.unwrap()
}
async fn kit_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_kit_assignments")
async fn active_item_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items")
.fetch_one(pool)
.await
.unwrap()
@@ -463,76 +519,160 @@ mod tests {
let (_dir, _url, pool) = fixture().await;
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
// ── active club item designations ──
#[tokio::test]
async fn kits_persist_across_reload_and_restart() {
async fn active_items_persist_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
.await
.expect("assign kits");
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
for (slot, id) in [
(ActiveSlot::HomeKit, "kit-home"),
(ActiveSlot::AwayKit, "kit-away"),
(ActiveSlot::Badge, "badge"),
(ActiveSlot::Ball, "ball"),
(ActiveSlot::Stadium, "stadium"),
] {
set_active_club_item(&pool, "club-a", slot, id)
.await
.expect("designate");
}
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(current.items.len(), 5, "every slot filled");
// Projection order is the canonical slot order, not DB insertion order.
assert_eq!(
current.home.as_ref().map(|item| item.id.as_str()),
Some("kit-home")
);
assert_eq!(
current.away.as_ref().map(|item| item.id.as_str()),
Some("kit-away")
current.items.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
ActiveSlot::ALL.to_vec()
);
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let persisted = get_active_club_kits(&reopened, "club-a").await.unwrap();
assert_eq!(persisted.home.map(|item| item.id), Some("kit-home".into()));
assert_eq!(persisted.away.map(|item| item.id), Some("kit-away".into()));
let persisted = get_active_club_items(&reopened, "club-a").await.unwrap();
assert_eq!(
persisted.get(ActiveSlot::Stadium).map(|c| c.id.as_str()),
Some("stadium"),
"designations must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn kits_replace_clear_and_never_duplicate() {
async fn active_item_replace_and_clear_never_duplicate() {
let (_dir, _url, pool) = fixture().await;
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away-2"))
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away-2")
.await
.unwrap();
assert_eq!(kit_rows(&pool).await, 2);
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
assert_eq!(current.away.map(|item| item.id), Some("kit-away-2".into()));
set_active_club_kits(&pool, "club-a", None, None)
.await
.unwrap();
assert_eq!(kit_rows(&pool).await, 0);
}
#[tokio::test]
async fn kits_reject_invalid_references_atomically() {
let (_dir, _url, pool) = fixture().await;
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
.await
.unwrap();
assert!(set_active_club_kits(&pool, "club-a", Some("foreign"), None)
.await
.is_err());
assert!(
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-home"))
.await
.is_err()
assert_eq!(active_item_rows(&pool).await, 1, "one item per slot");
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away-2")
);
let unchanged = get_active_club_kits(&pool, "club-a").await.unwrap();
assert_eq!(unchanged.home.map(|item| item.id), Some("kit-home".into()));
assert_eq!(unchanged.away.map(|item| item.id), Some("kit-away".into()));
assert_eq!(kit_rows(&pool).await, 2);
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 0);
// Clearing an empty slot is an idempotent no-op.
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
}
#[tokio::test]
async fn kit_delete_and_transfer_clear_active_designations() {
async fn active_item_rejects_unowned_card_and_leaves_state_intact() {
let (_dir, _url, pool) = fixture().await;
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "foreign")
.await
.is_err(),
"another club's item cannot be designated"
);
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "nope")
.await
.is_err(),
"a non-existent instance cannot be designated"
);
let unchanged = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
unchanged.get(ActiveSlot::HomeKit).map(|c| c.id.as_str()),
Some("kit-home")
);
assert_eq!(active_item_rows(&pool).await, 1);
}
#[tokio::test]
async fn active_item_rejects_slot_kind_mismatch() {
let (_dir, _url, pool) = fixture().await;
// A badge is not a kit; a player is not a stadium.
for (slot, id) in [
(ActiveSlot::HomeKit, "badge"),
(ActiveSlot::Stadium, "player"),
(ActiveSlot::Ball, "kit-home"),
] {
let err = set_active_club_item(&pool, "club-a", slot, id)
.await
.expect_err("slot/kind mismatch must be refused");
assert!(
matches!(err, AppError::BadRequest(_)),
"expected a bad-request, got {err:?}"
);
}
assert_eq!(active_item_rows(&pool).await, 0);
}
/// Lifecycle invariant: one owned instance can occupy at most ONE slot.
/// Re-designating it moves it rather than duplicating it.
#[tokio::test]
async fn one_instance_cannot_occupy_two_slots() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-home")
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 1);
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(current.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-home")
);
// The schema itself refuses the impossible state, not just the service.
let raw = sqlx::query(
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
VALUES ('club-a', 'home_kit', 'kit-home', ?)",
)
.bind(TS)
.execute(&pool)
.await;
assert!(
raw.is_err(),
"owned_card_id UNIQUE must reject a second slot"
);
}
/// Lifecycle invariant: a designation can never point at an item the club
/// does not own — neither after a quick sell (DELETE) nor after a transfer
/// (UPDATE of club_id, which no FK action can observe).
#[tokio::test]
async fn delete_and_transfer_clear_active_designations() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
@@ -540,19 +680,40 @@ mod tests {
.execute(&pool)
.await
.expect("quick sell kit");
let after_delete = get_active_club_kits(&pool, "club-a").await.unwrap();
assert!(after_delete.home.is_none());
let after_delete = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_delete.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
after_delete.away.map(|item| item.id),
Some("kit-away".into())
after_delete.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away")
);
assert_eq!(active_item_rows(&pool).await, 1);
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
.execute(&pool)
.await
.expect("transfer kit");
assert_eq!(kit_rows(&pool).await, 0);
let after_transfer = get_active_club_kits(&pool, "club-a").await.unwrap();
assert!(after_transfer.home.is_none() && after_transfer.away.is_none());
assert_eq!(active_item_rows(&pool).await, 0, "transfer clears the slot");
let after_transfer = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_transfer.items.is_empty());
}
/// A designation whose owned row is forced out of the club WITHOUT the
/// trigger firing (raw row surgery mimicking corruption) must still never
/// project: reads revalidate ownership.
#[tokio::test]
async fn read_revalidates_ownership_of_a_stale_designation() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::Badge, "badge")
.await
.unwrap();
sqlx::query("UPDATE club_active_items SET owned_card_id = 'foreign' WHERE slot = 'badge'")
.execute(&pool)
.await
.expect("corrupt the designation");
let items = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(
items.get(ActiveSlot::Badge).is_none(),
"another club's item must never be projected"
);
}
}
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -20,6 +20,7 @@
//! - The whole thing commits together or not at all.
use crate::db::Pool;
use crate::models::card::ContentKind;
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
use crate::services::card_db::CardDb;
use crate::services::squad::squad_fingerprint;
@@ -49,6 +50,15 @@ pub struct ImportOwnedCard {
pub owned_item_id: String,
/// CardDefinitionId that MUST resolve in loaded production content.
pub card_id: String,
/// Generic content classification. Absent = `player`, which is what every
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
#[serde(default)]
pub content_kind: ContentKind,
/// Optional per-instance stack size (a consumable's wire `amount`). Absent /
/// `null` means "not a stack"; it never collapses two instances into one row.
#[serde(default)]
pub quantity: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -127,6 +137,19 @@ pub async fn apply_profile_import(
if req.owned.is_empty() {
bail!("import request has zero owned cards; refusing to import an empty profile");
}
// A stack size is either absent ("not a stack") or a real positive count.
// Reject an explicit 0/negative up front rather than letting the column
// CHECK surface it as an opaque constraint failure mid-transaction.
for o in &req.owned {
if let Some(q) = o.quantity {
if q < 1 {
bail!(
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
o.owned_item_id
);
}
}
}
// ── 1. rerun identity / single-profile-per-game ──
let existing: Option<(String, Option<String>)> = sqlx::query_as(
@@ -242,13 +265,17 @@ pub async fn apply_profile_import(
for o in &req.owned {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
"INSERT INTO owned_cards \
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
content_kind, quantity) \
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
)
.bind(&o.owned_item_id)
.bind(&club_id)
.bind(&o.card_id)
.bind(&now)
.bind(o.content_kind.as_str())
.bind(o.quantity)
.execute(&mut *tx)
.await
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
+60 -2
View File
@@ -15,7 +15,7 @@
use serde::Deserialize;
use crate::models::card::Quality;
use crate::models::card::{ContentKind, Quality};
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
@@ -25,6 +25,9 @@ pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Owned-content kind (player/consumable/kit/…). Serialized lowercase.
#[serde(default)]
pub content_kind: Option<ContentKind>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
@@ -47,8 +50,12 @@ pub struct OwnedItemQuery {
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
#[derive(Clone)]
pub struct OwnedItemView {
pub owned_card_id: String,
/// What kind of content this instance is; lets a caller filter without
/// re-deriving the taxonomy from definition fields.
pub content_kind: ContentKind,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
@@ -78,6 +85,10 @@ pub struct QueryPage {
/// 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 kind_ok = q
.content_kind
.map(|want| item.content_kind == want)
.unwrap_or(true);
let pos_ok = q
.position
.as_ref()
@@ -98,7 +109,7 @@ fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && pos_ok && nation_ok && league_ok && club_ok
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
@@ -152,6 +163,7 @@ mod tests {
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
content_kind: ContentKind::Player,
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
@@ -179,6 +191,52 @@ mod tests {
]
}
/// A club holds mixed content; a caller asking for one kind must get exactly
/// that kind, and the unfiltered read must still return everything.
#[test]
fn content_kind_filters_mixed_inventory() {
let mut items = fixture();
let mut kit = view("k", 0, "", "", "", "");
kit.content_kind = ContentKind::Kit;
let mut style = view("s", 0, "", "", "", "");
style.content_kind = ContentKind::Consumable;
items.push(kit);
items.push(style);
let all = apply_query(items.clone(), &OwnedItemQuery::default());
assert_eq!(all.total, 7, "no filter returns every kind");
let kits = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Kit),
..Default::default()
},
);
assert_eq!(ids(&kits), ["k"]);
let players = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Player),
..Default::default()
},
);
assert_eq!(players.total, 5);
let none = apply_query(
items,
&OwnedItemQuery {
content_kind: Some(ContentKind::Stadium),
..Default::default()
},
);
assert_eq!(
none.total, 0,
"a kind the club owns none of is empty, not everything"
);
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
+4 -5
View File
@@ -208,11 +208,10 @@ pub async fn sell_card(
return Err(AppError::BadRequest("price must be non-negative".into()));
}
let owned = sqlx::query_as::<_, crate::models::card::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 = ? AND club_id = ?",
)
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
"{} WHERE id = ? AND club_id = ?",
crate::models::card::OWNED_CARD_SELECT
))
.bind(&req.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
+4 -5
View File
@@ -3,7 +3,7 @@ use crate::{
error::{AppError, AppResult},
models::{
achievement::AchievementDefinition,
card::OwnedCard,
card::{OwnedCard, OWNED_CARD_SELECT},
match_result::{CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind},
objective::ObjectiveDefinition,
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
@@ -142,10 +142,9 @@ async fn expire_loans_tx(
let mut expired = Vec::new();
for (_sp_id, owned_id) in starters {
let card = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ? AND is_loan = 1",
)
let card = sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND is_loan = 1"
))
.bind(&owned_id)
.fetch_optional(&mut **tx)
.await?;
+1
View File
@@ -2,6 +2,7 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod consume;
pub mod draft;
pub mod economy;
pub mod event;
+4 -5
View File
@@ -350,11 +350,10 @@ async fn submit_sbc_transaction(
let mut cards = Vec::with_capacity(owned_card_ids.len());
for owned_id in owned_card_ids {
let row = sqlx::query_as::<_, crate::models::card::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 = ? AND club_id = ?",
)
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
"{} WHERE id = ? AND club_id = ?",
crate::models::card::OWNED_CARD_SELECT
))
.bind(owned_id)
.bind(club_id)
.fetch_optional(&mut **tx)
+21 -19
View File
@@ -2,7 +2,7 @@ use crate::{
db::Pool,
error::{AppError, AppResult},
models::{
card::{CardDefinition, OwnedCard},
card::{CardDefinition, OwnedCard, OWNED_CARD_SELECT},
game_ext::{GameEntityExt, OpaqueExtensionWrite},
squad::{
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
@@ -88,14 +88,19 @@ pub async fn validate_formation(
let mut gk_count = 0usize;
for sp in &starters {
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 = ? AND club_id = ?",
)
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(&sp.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
.ok_or_else(|| {
AppError::NotFound(format!(
"owned card {} not found or does not belong to this club",
sp.owned_card_id
))
})?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {
@@ -137,13 +142,10 @@ pub async fn calculate_chemistry(
// Load all starter card definitions (N separate queries, fine for 11 players)
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ?",
)
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ?"))
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) {
@@ -270,13 +272,13 @@ async fn replace_squad_inner(
// 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)))?;
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} 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
+1 -6
View File
@@ -1,15 +1,10 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::card::OwnedCard,
models::card::{OwnedCard, OWNED_CARD_SELECT},
models::chemistry_style::ChemistryStyle,
};
const OWNED_CARD_SELECT: &str =
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards";
pub const MAX_TRAINING_BONUS: i64 = 3;
/// Cost in coins to change a player's position.