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], + }) +}