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
+185
View File
@@ -3378,3 +3378,188 @@ async fn test_economy_settle_sale_route_rejects_self_dealing() {
Some(SELLER_CLUB)
);
}
// ── generic active club-item designations + collection taxonomy ───────────────
/// Insert one owned instance of a given content kind directly, since there is no
/// route that grants a kit/badge/ball/stadium yet (the game adapter/import does).
async fn seed_owned_kind(
pool: &sqlx::SqlitePool,
id: &str,
club_id: &str,
card_id: &str,
kind: &str,
) {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
VALUES (?, ?, ?, 0, '2026-01-01T00:00:00Z', ?)",
)
.bind(id)
.bind(club_id)
.bind(card_id)
.bind(kind)
.execute(pool)
.await
.expect("seed owned item");
}
async fn club_id_of(pool: &sqlx::SqlitePool) -> String {
sqlx::query_scalar::<_, String>("SELECT id FROM clubs LIMIT 1")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn test_active_items_get_returns_every_slot_explicitly() {
let (app, _pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let (s, j) = json_get(&app, "/club/active-items").await;
assert_eq!(s, StatusCode::OK, "{j}");
for slot in ["home_kit", "away_kit", "badge", "ball", "stadium"] {
assert!(
j["active_items"][slot].is_null(),
"slot {slot} must be present and null on a fresh club: {j}"
);
}
}
#[tokio::test]
async fn test_active_items_put_set_and_clear_roundtrip() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
// A real definition id keeps the collection projection honest; the kind is
// what the designation validates against.
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": "kit-1" }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
assert_eq!(j["active_items"]["home_kit"]["content_kind"], "kit");
assert!(j["active_items"]["badge"].is_null());
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "badge", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
// A null owned_card_id clears just that slot.
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": null }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert!(j["active_items"]["home_kit"].is_null());
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
// The designation is durable, not per-response.
let (_, j) = json_get(&app, "/club/active-items").await;
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
}
#[tokio::test]
async fn test_active_items_put_rejects_kind_and_ownership_violations() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
// A badge is not a kit.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// An item the club does not own.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "badge", "owned_card_id": "nope" }),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// A slot outside the recovered equipped-state vocabulary.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "league_logo", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (_, j) = json_get(&app, "/club/active-items").await;
assert!(j["active_items"]["home_kit"].is_null());
assert!(j["active_items"]["badge"].is_null());
}
#[tokio::test]
async fn test_collection_carries_content_kind_and_filters_on_it() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
seed_owned_kind(&pool, "player-1", &club, "card_bronze_002", "player").await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "{j}");
let kinds: Vec<&str> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|c| c["content_kind"].as_str().expect("content_kind present"))
.collect();
assert!(kinds.contains(&"kit"), "kinds: {kinds:?}");
assert!(kinds.contains(&"player"), "kinds: {kinds:?}");
let (_, only_kits) = json_get(&app, "/collection?content_kind=kit").await;
assert_eq!(only_kits["total"], 1);
assert_eq!(only_kits["collection"][0]["owned_card_id"], "kit-1");
let (_, none) = json_get(&app, "/collection?content_kind=stadium").await;
assert_eq!(none["total"], 0);
}
#[tokio::test]
async fn test_collection_reports_owned_rows_it_cannot_project() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
// An owned row whose definition is NOT in loaded content: it cannot be
// projected, but it must be counted and named, never silently dropped.
seed_owned_kind(&pool, "ghost", &club, "definitely_absent_999", "consumable").await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "a missing definition must not 500: {j}");
assert_eq!(j["unresolved_items"], 1);
assert_eq!(j["unresolved_definitions"][0], "definitely_absent_999");
assert_eq!(
j["owned_rows"].as_i64().unwrap(),
j["total"].as_i64().unwrap() + 1,
"owned_rows is ownership truth, total is what could be projected: {j}"
);
let ids: Vec<&str> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|c| c["owned_card_id"].as_str().unwrap())
.collect();
assert!(!ids.contains(&"ghost"));
}