From a45155e0c51dd4cf41df9295352db110044d225a Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 22:59:24 +0000 Subject: [PATCH] feat(core): per-instance attribute training as a closed effect FIFA 17 training cards boost ONE attribute of ONE owned player. Core gains the state to hold that and the vocabulary to be asked for it, without learning any FIFA rule. `owned_card_training` (migration 0029) keys on (owned_card_id, attribute_index), so a second training on a slot that already carries one is a constraint violation rather than a silent choice between stacking and replacing. Whether FIFA 17 stacks, replaces, merges or refuses is UNKNOWN -- no shipped table describes it and the client holds no consumable-effect logic to reverse it from -- so the schema enforces the unknown and the apply turns it into a refusal that consumes nothing. Relaxing that later is one line; unpicking accumulated wrong state would not be. `InstanceEffect::ApplyTraining { attribute_index, amount, max_amount }` names a SLOT in Core's own six-attribute model, never a FIFA attribute: that "GK speed is slot 4" is the adapter's reversed knowledge and stays there. The caller declares its family's authored ceiling and Core holds it to it, which is what stops a host describing a boost no card could grant through a vocabulary that exists to prevent exactly that. The immutable definition is never written. `/collection` gains `effective_attributes` (base + training, clamped to the 1..=99 domain) and the raw effects, loaded for the whole club in one query rather than the N+1 this projection has suffered before. The legacy `training_bonus` column -- an overall-rating upgrade written only by a non-transactional route no adapter calls -- is deliberately not reused. Tests cover the happy path, same-slot refusal leaving the card intact, distinct slots coexisting, over-ceiling and out-of-range refusals, loan refusal, replay, FK cascade, and two 12-round races: apply vs quick-sell on one card, and two concurrent applies of one card. Both prove exactly one winner, one effect, one audit row. --- migrations/0029_owned_card_training.sql | 49 +++ src/routes/cards.rs | 19 +- src/services/instance_effect.rs | 487 ++++++++++++++++++++++++ src/services/mod.rs | 1 + src/services/training.rs | 118 ++++++ 5 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 migrations/0029_owned_card_training.sql create mode 100644 src/services/training.rs diff --git a/migrations/0029_owned_card_training.sql b/migrations/0029_owned_card_training.sql new file mode 100644 index 0000000..ef7bf8a --- /dev/null +++ b/migrations/0029_owned_card_training.sql @@ -0,0 +1,49 @@ +-- Per-instance attribute training on an owned instance. +-- +-- WHY A TABLE AND NOT COLUMNS. A training effect is (attribute slot, amount), +-- and a game may author one per slot. Six nullable columns would encode the +-- slot in the schema and force a migration to add a seventh; a row per slot +-- keeps the slot a value. It is also the smallest shape that lets the PRIMARY +-- KEY do the work described below. +-- +-- WHY THE PRIMARY KEY IS (owned_card_id, attribute_index). Whether FIFA 17 +-- REPLACES, STACKS, MERGES or REFUSES a second training on an attribute that +-- already carries one is UNKNOWN: no shipped table encodes it, and the client +-- holds no consumable-effect logic at all to reverse (no binary in the install +-- reads `fcc_trainingcards`, so effects are server-authoritative). Rather than +-- pick one of those behaviours and ship a guess as though it were recovered, +-- the key makes a second application to the SAME slot a constraint violation, +-- which the apply path turns into an explicit refusal that consumes nothing. +-- The unknown is therefore enforced by the schema instead of being papered over. +-- When the behaviour is proven, the change is a deliberate one-line relaxation +-- plus the arithmetic it implies — not an unpicking of accumulated bad state. +-- +-- `attribute_index` is a slot in CORE's own six-attribute card model, in the +-- declaration order of `CardDefinition` (0 pace, 1 shooting, 2 passing, +-- 3 dribbling, 4 defending, 5 physical). It is deliberately NOT a FIFA +-- attribute name: mapping "GK speed" onto slot 4 is the FIFA 17 adapter's +-- reversed knowledge, and Core stays game-neutral by only ever indexing its own +-- model. The CHECK pins the slot to that model's width. +-- +-- `amount` is bounded at 99 because it is added to an attribute whose domain is +-- 1..=99; a larger stored value could not mean anything. The tighter, per-game +-- ceiling (FIFA 17 authors only 5/10/15) is validated at apply time, where the +-- game's table is in scope, not here. +-- +-- ON DELETE CASCADE is load-bearing: the pool enables `foreign_keys` +-- (`db.rs:20`), so quick-selling or otherwise destroying a trained instance +-- takes its training with it and cannot leave a row pointing at a dead item. +CREATE TABLE owned_card_training ( + owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE, + attribute_index INTEGER NOT NULL CHECK (attribute_index BETWEEN 0 AND 5), + amount INTEGER NOT NULL CHECK (amount >= 1 AND amount <= 99), + -- The definition that granted it, kept for audit and for the eventual + -- lifecycle work; Core never interprets it. + source_card_id TEXT NOT NULL, + applied_at TEXT NOT NULL, + PRIMARY KEY (owned_card_id, attribute_index) +); + +-- The projection reads every effect for a set of instances on each /collection +-- call, so the lookup is by instance. +CREATE INDEX idx_owned_card_training_owned ON owned_card_training(owned_card_id); diff --git a/src/routes/cards.rs b/src/routes/cards.rs index c060181..7afc8fe 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -13,7 +13,7 @@ use crate::{ services::{ club as club_svc, economy as economy_svc, inventory::{self, OwnedItemQuery, OwnedItemView}, - profile as profile_svc, + profile as profile_svc, training as training_svc, }, }; @@ -119,6 +119,11 @@ pub async fn get_collection( .fetch_all(&state.pool) .await?; + // One query for the whole club, not one per item: this projection walks + // every owned row, and a per-item lookup here is the N+1 it has suffered + // before. + let training = training_svc::load_for_club(&state.pool, &club.id).await?; + // An owned row whose definition is absent from the loaded content CANNOT be // projected (there is nothing to project), but it must never vanish in // silence: that silent `filter_map` drop is how a real club once served @@ -141,6 +146,16 @@ pub async fn get_collection( }; let effective_overall = def.overall as i64 + o.training_bonus; let effective_position = o.position_override.as_deref().unwrap_or(&def.position); + // Attribute training is per-instance state, so the finished attributes + // belong in the envelope beside the finished rating. The raw effects go + // out too: a caller that needs to show WHICH attribute was trained + // cannot recover that by differencing against a definition it may not + // have. + const NO_TRAINING: &[training_svc::TrainingEffect] = &[]; + let effects = training + .get(&o.id) + .map(Vec::as_slice) + .unwrap_or(NO_TRAINING); let body = json!({ "owned_card_id": o.id, "content_kind": o.content_kind, @@ -157,6 +172,8 @@ pub async fn get_collection( "contract_matches": o.contract_matches, "effective_overall": effective_overall, "effective_position": effective_position, + "effective_attributes": training_svc::effective_attributes_json(def, effects), + "training": effects, "card": def, }); views.push(OwnedItemView { diff --git a/src/services/instance_effect.rs b/src/services/instance_effect.rs index 67f3bc7..7bfc46a 100644 --- a/src/services/instance_effect.rs +++ b/src/services/instance_effect.rs @@ -18,6 +18,7 @@ //! present and future invariant unenforceable; a new effect is a new variant, //! validated here, reviewed here. +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::SqliteConnection; @@ -43,6 +44,23 @@ pub enum InstanceEffect { cap: i64, default_when_unset: i64, }, + /// Attach an attribute training effect to the target. + /// + /// `attribute_index` is a slot in Core's own six-attribute card model, in + /// `CardDefinition` declaration order (0 pace .. 5 physical). Naming the + /// slot rather than the game's attribute is what keeps this game-neutral: + /// that FIFA 17's "GK speed" is slot 4 is the adapter's reversed knowledge, + /// and it stays there. + /// + /// `max_amount` is the caller's authored ceiling for its own family (FIFA 17 + /// authors 5/10/15, so 15). Core cannot know it, but it can refuse anything + /// above the number the caller itself declares, which is what stops a host + /// from describing a "+99 pace" that no card could grant. + ApplyTraining { + attribute_index: i64, + amount: i64, + max_amount: i64, + }, } impl ItemMutation for InstanceEffect { @@ -63,6 +81,17 @@ impl ItemMutation for InstanceEffect { *cap, *default_when_unset, )), + InstanceEffect::ApplyTraining { + attribute_index, + amount, + max_amount, + } => Box::pin(apply_training( + tx, + ctx, + *attribute_index, + *amount, + *max_amount, + )), } } } @@ -156,6 +185,100 @@ async fn add_contract_matches( })) } +/// Core's own six-attribute card model is this wide. A slot outside it cannot +/// name anything Core can project. +const ATTRIBUTE_SLOTS: i64 = 6; + +async fn apply_training( + tx: &mut SqliteConnection, + ctx: &ConsumeContext, + attribute_index: i64, + amount: i64, + max_amount: i64, +) -> AppResult { + let target = require_target(ctx, "apply_training")?; + + if !(0..ATTRIBUTE_SLOTS).contains(&attribute_index) { + return Err(AppError::BadRequest(format!( + "apply_training attribute_index must be 0..{ATTRIBUTE_SLOTS}, got {attribute_index}" + ))); + } + if amount < 1 { + return Err(AppError::BadRequest(format!( + "apply_training amount must be >= 1, got {amount}" + ))); + } + // The caller declares its own family's authored ceiling and is then held to + // it. Without this a host could describe an arbitrary boost through a + // vocabulary that exists precisely to prevent that. + if !(1..=99).contains(&max_amount) { + return Err(AppError::BadRequest(format!( + "apply_training max_amount must be 1..=99, got {max_amount}" + ))); + } + if amount > max_amount { + return Err(AppError::BadRequest(format!( + "apply_training amount {amount} exceeds the caller's declared maximum {max_amount}" + ))); + } + // Same reasoning as contracts: a loan is borrowed for a fixed run of + // matches, so durably improving it would outlive the thing it is attached + // to. Conservative and consistent rather than reversed — no FIFA 17 source + // speaks to training a loan item. + if target.is_loan { + return Err(AppError::BadRequest(format!( + "training cannot be applied to a loan item ('{}')", + target.id + ))); + } + + // The INSERT is the check. `(owned_card_id, attribute_index)` is the primary + // key, so a second training on a slot that already carries one collides here + // and the whole transaction rolls back — the source is NOT consumed. That is + // deliberate: replacement-vs-stacking is UNKNOWN (see migration 0029), and a + // refusal is the only answer that neither invents a rule nor silently eats a + // card. Detected as a constraint violation rather than a SELECT-then-INSERT + // so it holds against a concurrent writer instead of racing it. + let inserted = sqlx::query( + "INSERT INTO owned_card_training \ + (owned_card_id, attribute_index, amount, source_card_id, applied_at) \ + VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT(owned_card_id, attribute_index) DO NOTHING", + ) + .bind(&target.id) + .bind(attribute_index) + .bind(amount) + .bind(&ctx.source.card_id) + .bind(Utc::now().to_rfc3339()) + .execute(&mut *tx) + .await? + .rows_affected(); + + if inserted != 1 { + return Err(AppError::Conflict(format!( + "target item '{}' already carries training on attribute slot {attribute_index}; \ + FIFA 17 replacement/stacking behaviour is unproven, so this is refused rather \ + than guessed", + target.id + ))); + } + + Ok(json!({ + "kind": "apply_training", + "attribute_index": attribute_index, + "amount": amount, + // Named `granted` as well as `amount` so every effect's recorded outcome + // answers "what did this card award" under one key, whatever the family. + "granted": amount, + // `before`/`after` describe the TRAINING held on this slot, not the + // attribute's value: Core stores effects, and the attribute total is a + // projection over a definition Core does not consult here. `before` is + // always 0 because a slot that already carried training refused above. + "before": 0, + "after": amount, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -244,6 +367,370 @@ mod tests { } } + fn train(attribute_index: i64, amount: i64) -> InstanceEffect { + InstanceEffect::ApplyTraining { + attribute_index, + amount, + max_amount: 15, + } + } + + async fn training_of(pool: &db::Pool, id: &str) -> Vec<(i64, i64, String)> { + sqlx::query_as::<_, (i64, i64, String)>( + "SELECT attribute_index, amount, source_card_id FROM owned_card_training \ + WHERE owned_card_id = ? ORDER BY attribute_index", + ) + .bind(id) + .fetch_all(pool) + .await + .expect("read training") + } + + async fn source_exists(pool: &db::Pool, id: &str) -> bool { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?") + .bind(id) + .fetch_one(pool) + .await + .expect("count source") + == 1 + } + + /// The whole point: one card, one slot, one consumed source, recorded + /// against the definition that granted it. + #[tokio::test] + async fn training_attaches_to_the_slot_and_spends_the_card() { + let (_dir, pool) = fixture().await; + let out = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(4, 10), + ) + .await + .expect("apply"); + + assert!(out.applied); + assert!(out.source_destroyed); + assert_eq!( + out.effect, + json!({ + "kind": "apply_training", + "attribute_index": 4, + "amount": 10, + "granted": 10, + "before": 0, + "after": 10, + }) + ); + assert_eq!( + training_of(&pool, "fresh").await, + vec![(4, 10, "def-card-1".to_string())] + ); + assert!(!source_exists(&pool, "card-1").await); + } + + /// Replacement-vs-stacking is UNKNOWN, so a second card on the SAME slot is + /// refused — and, critically, the refusal rolls back the whole transaction, + /// so the player keeps the card rather than paying for nothing. + #[tokio::test] + async fn a_second_training_on_the_same_slot_is_refused_and_the_card_survives() { + let (_dir, pool) = fixture().await; + consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(4, 10), + ) + .await + .expect("first apply"); + + let err = consume_item( + &pool, + "prof", + "club", + &apply_request("act-2", "card-2", "fresh"), + &train(4, 15), + ) + .await + .expect_err("second apply on the same slot must be refused"); + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); + + // The original effect is untouched: not replaced, not stacked. + assert_eq!( + training_of(&pool, "fresh").await, + vec![(4, 10, "def-card-1".to_string())] + ); + // And the second card was NOT spent. + assert!(source_exists(&pool, "card-2").await); + } + + /// Distinct slots are independent: each training card names exactly one + /// attribute, and nothing in the shipped data couples them. + #[tokio::test] + async fn distinct_slots_coexist_on_one_instance() { + let (_dir, pool) = fixture().await; + for (identity, source, slot, amount) in + [("act-1", "card-1", 4, 10), ("act-2", "card-2", 1, 5)] + { + consume_item( + &pool, + "prof", + "club", + &apply_request(identity, source, "fresh"), + &train(slot, amount), + ) + .await + .expect("apply"); + } + assert_eq!( + training_of(&pool, "fresh").await, + vec![ + (1, 5, "def-card-2".to_string()), + (4, 10, "def-card-1".to_string()) + ] + ); + } + + /// The caller declares its own family's ceiling and is held to it. This is + /// what stops a host describing a boost no card could grant. + #[tokio::test] + async fn an_amount_above_the_callers_declared_maximum_is_refused() { + let (_dir, pool) = fixture().await; + let err = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(0, 99), + ) + .await + .expect_err("over-max must be refused"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert!(training_of(&pool, "fresh").await.is_empty()); + assert!(source_exists(&pool, "card-1").await); + } + + /// A slot outside Core's six-attribute model names nothing projectable. + #[tokio::test] + async fn a_slot_outside_the_card_model_is_refused() { + let (_dir, pool) = fixture().await; + for slot in [-1, 6, 99] { + let err = consume_item( + &pool, + "prof", + "club", + &apply_request("act-x", "card-1", "fresh"), + &train(slot, 5), + ) + .await + .expect_err("out-of-range slot must be refused"); + assert!( + matches!(err, AppError::BadRequest(_)), + "slot {slot}: {err:?}" + ); + } + assert!(source_exists(&pool, "card-1").await); + } + + /// Same reasoning as contracts: a loan outlives neither its match budget nor + /// the improvement, so training it is refused rather than quietly wasted. + #[tokio::test] + async fn training_a_loan_item_is_refused_and_the_source_survives() { + let (_dir, pool) = fixture().await; + let err = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "loaned"), + &train(0, 5), + ) + .await + .expect_err("loan target must be refused"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert!(training_of(&pool, "loaned").await.is_empty()); + assert!(source_exists(&pool, "card-1").await); + } + + /// A transport retry of the SAME action must not train twice or spend two + /// cards — the guard is the same one contracts rely on. + #[tokio::test] + async fn a_training_replay_neither_trains_nor_charges_twice() { + let (_dir, pool) = fixture().await; + let first = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(2, 15), + ) + .await + .expect("first"); + assert!(first.applied); + + let replay = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(2, 15), + ) + .await + .expect("replay"); + assert!(!replay.applied, "a replay must not report a fresh apply"); + assert_eq!(replay.effect, first.effect); + assert_eq!( + training_of(&pool, "fresh").await, + vec![(2, 15, "def-card-1".to_string())] + ); + } + + /// Destroying a trained instance must not leave its training behind — the + /// FK cascade is what guarantees a quick-sold card cannot haunt the table. + #[tokio::test] + async fn training_dies_with_the_instance_it_is_attached_to() { + let (_dir, pool) = fixture().await; + consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &train(3, 5), + ) + .await + .expect("apply"); + assert_eq!(training_of(&pool, "fresh").await.len(), 1); + + sqlx::query("DELETE FROM owned_cards WHERE id = 'fresh'") + .execute(&pool) + .await + .expect("delete instance"); + assert!(training_of(&pool, "fresh").await.is_empty()); + } + + /// APPLY vs QUICK-SELL on the LAST copy of a source. Exactly one may win: + /// the card is either spent on the target or sold for coins, never both. + /// + /// Both paths are single Core transactions over the same row, so the loser + /// must fail rather than operate on an already-gone source. This is the race + /// a real player creates by hammering Enter on the consumables screen while a + /// quick-sell is in flight. + #[tokio::test] + async fn apply_and_quick_sell_cannot_both_spend_one_card() { + use crate::services::economy::{self, SaleBuyer, SaleTerms}; + + // Repeated because a race that only sometimes interleaves would pass by + // luck on a single attempt. + for round in 0..12 { + let (_dir, pool) = fixture().await; + let apply_pool = pool.clone(); + let sell_pool = pool.clone(); + + let applied = tokio::spawn(async move { + consume_item( + &apply_pool, + "prof", + "club", + &apply_request("act-race", "card-1", "fresh"), + &train(0, 5), + ) + .await + }); + let sold = tokio::spawn(async move { + economy::settle_sale( + &sell_pool, + "card-1", + "club", + SaleBuyer::Outside, + SaleTerms { gross: 100, fee: 0 }, + ) + .await + }); + + let applied = applied.await.expect("apply task"); + let sold = sold.await.expect("sell task"); + + let trained = !training_of(&pool, "fresh").await.is_empty(); + let coins = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id='club'") + .fetch_one(&pool) + .await + .expect("coins"); + + match (applied.is_ok(), sold.is_ok()) { + (true, false) => { + assert!(trained, "round {round}: apply won but left no training"); + assert_eq!(coins, 0, "round {round}: apply won but coins moved"); + } + (false, true) => { + assert!(!trained, "round {round}: sale won but training was written"); + assert_eq!(coins, 100, "round {round}: sale won but paid nothing"); + } + (a, s) => panic!("round {round}: exactly one must win, got apply={a} sale={s}"), + } + // Either way the card is gone exactly once. + assert!( + !source_exists(&pool, "card-1").await, + "round {round}: the source survived a winner" + ); + } + } + + /// Two CONCURRENT applies of the same last copy, under DIFFERENT identities + /// (so the replay guard is not what separates them) and onto different + /// slots. One must win outright: one training written, one card spent, one + /// audit row. + #[tokio::test] + async fn two_concurrent_applies_of_one_card_produce_exactly_one_effect() { + for round in 0..12 { + let (_dir, pool) = fixture().await; + let a_pool = pool.clone(); + let b_pool = pool.clone(); + + let a = tokio::spawn(async move { + consume_item( + &a_pool, + "prof", + "club", + &apply_request("act-a", "card-1", "fresh"), + &train(0, 5), + ) + .await + }); + let b = tokio::spawn(async move { + consume_item( + &b_pool, + "prof", + "club", + &apply_request("act-b", "card-1", "fresh"), + &train(1, 5), + ) + .await + }); + let (a, b) = (a.await.expect("a"), b.await.expect("b")); + + assert!( + a.is_ok() ^ b.is_ok(), + "round {round}: exactly one apply must win, got a={:?} b={:?}", + a.is_ok(), + b.is_ok() + ); + assert_eq!( + training_of(&pool, "fresh").await.len(), + 1, + "round {round}: exactly one training effect must exist" + ); + let audits = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM consumable_applications WHERE profile_id = 'prof'", + ) + .fetch_one(&pool) + .await + .expect("audit count"); + assert_eq!(audits, 1, "round {round}: exactly one audit row"); + assert!(!source_exists(&pool, "card-1").await); + } + } + async fn contract_of(pool: &db::Pool, id: &str) -> Option { sqlx::query_scalar::<_, Option>( "SELECT contract_matches FROM owned_cards WHERE id = ?", diff --git a/src/services/mod.rs b/src/services/mod.rs index b23694e..aa60a00 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -23,4 +23,5 @@ pub mod settings; pub mod squad; pub mod squad_rules; pub mod statistics; +pub mod training; pub mod upgrades; diff --git a/src/services/training.rs b/src/services/training.rs new file mode 100644 index 0000000..7995dea --- /dev/null +++ b/src/services/training.rs @@ -0,0 +1,118 @@ +//! Reading per-instance attribute training back out for projection. +//! +//! Writing is [`crate::services::instance_effect::InstanceEffect::ApplyTraining`], +//! inside the one apply transaction. This module is the read half: it loads the +//! effects a club's instances carry and folds them onto a definition's +//! attributes. +//! +//! The fold lives in Core rather than in each game host on purpose. The stored +//! effect names a SLOT in Core's own card model, so only Core knows which field +//! slot 4 is; a host that did the arithmetic itself would have to re-derive that +//! mapping and could disagree with the next host. Core answers with the finished +//! numbers and the raw effects, and the host chooses which it needs. + +use std::collections::HashMap; + +use serde::Serialize; +use sqlx::FromRow; + +use crate::{db::Pool, error::AppResult, models::card::CardDefinition}; + +/// The upper bound of a FIFA-style attribute. Training is added to a value whose +/// domain is 1..=99, so the fold clamps there. +/// +/// This is a DOMAIN invariant of the six-attribute card model, not a reversed +/// training rule: whether FIFA 17 itself refuses to train a 95-pace player past +/// 99, or clamps like this, or wraps, is UNKNOWN. Clamping is the only behaviour +/// that keeps the projected card inside the model it is drawn from. +pub const ATTRIBUTE_MAX: i64 = 99; + +/// One training effect attached to one instance. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TrainingEffect { + /// Slot in Core's six-attribute model, `CardDefinition` declaration order. + pub attribute_index: i64, + pub amount: i64, + pub source_card_id: String, +} + +/// Every training effect held by the given club's instances, keyed by instance. +/// +/// One query for the whole club rather than one per item: the projection walks +/// up to a couple of thousand owned rows, and a per-item lookup there is the +/// classic N+1 that has bitten this projection before. +pub async fn load_for_club( + pool: &Pool, + club_id: &str, +) -> AppResult>> { + let rows = sqlx::query_as::<_, (String, i64, i64, String)>( + "SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id \ + FROM owned_card_training t \ + JOIN owned_cards o ON o.id = t.owned_card_id \ + WHERE o.club_id = ? \ + ORDER BY t.owned_card_id, t.attribute_index", + ) + .bind(club_id) + .fetch_all(pool) + .await?; + + let mut by_instance: HashMap> = HashMap::new(); + for (owned_card_id, attribute_index, amount, source_card_id) in rows { + by_instance + .entry(owned_card_id) + .or_default() + .push(TrainingEffect { + attribute_index, + amount, + source_card_id, + }); + } + Ok(by_instance) +} + +/// The definition's six attributes in canonical slot order. +/// +/// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is +/// `CardDefinition`'s own declaration order, and changing it would silently +/// re-point every stored effect at a different attribute. +pub fn base_attributes(def: &CardDefinition) -> [i64; 6] { + [ + def.pace as i64, + def.shooting as i64, + def.passing as i64, + def.dribbling as i64, + def.defending as i64, + def.physical as i64, + ] +} + +/// Base attributes with any training folded in, clamped to the model's domain. +/// +/// An out-of-range slot is ignored rather than panicking: the schema already +/// refuses one, so reaching this would mean the row was written around Core, and +/// dropping it degrades one attribute instead of failing every projection. +pub fn effective_attributes(def: &CardDefinition, effects: &[TrainingEffect]) -> [i64; 6] { + let mut out = base_attributes(def); + for e in effects { + if let Some(slot) = out.get_mut(e.attribute_index as usize) { + *slot = (*slot + e.amount).clamp(0, ATTRIBUTE_MAX); + } + } + out +} + +/// The same six values as a named object, for the projection envelope. +pub fn effective_attributes_json( + def: &CardDefinition, + effects: &[TrainingEffect], +) -> serde_json::Value { + let a = effective_attributes(def, effects); + serde_json::json!({ + "pace": a[0], + "shooting": a[1], + "passing": a[2], + "dribbling": a[3], + "defending": a[4], + "physical": a[5], + }) +}