Compare commits
1 Commits
82c3d2c85a
...
a45155e0c5
| Author | SHA1 | Date | |
|---|---|---|---|
| a45155e0c5 |
@@ -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);
|
||||||
+18
-1
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
services::{
|
services::{
|
||||||
club as club_svc, economy as economy_svc,
|
club as club_svc, economy as economy_svc,
|
||||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.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
|
// 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
|
// 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
|
// 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_overall = def.overall as i64 + o.training_bonus;
|
||||||
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
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!({
|
let body = json!({
|
||||||
"owned_card_id": o.id,
|
"owned_card_id": o.id,
|
||||||
"content_kind": o.content_kind,
|
"content_kind": o.content_kind,
|
||||||
@@ -157,6 +172,8 @@ pub async fn get_collection(
|
|||||||
"contract_matches": o.contract_matches,
|
"contract_matches": o.contract_matches,
|
||||||
"effective_overall": effective_overall,
|
"effective_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
|
"effective_attributes": training_svc::effective_attributes_json(def, effects),
|
||||||
|
"training": effects,
|
||||||
"card": def,
|
"card": def,
|
||||||
});
|
});
|
||||||
views.push(OwnedItemView {
|
views.push(OwnedItemView {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
//! present and future invariant unenforceable; a new effect is a new variant,
|
//! present and future invariant unenforceable; a new effect is a new variant,
|
||||||
//! validated here, reviewed here.
|
//! validated here, reviewed here.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::SqliteConnection;
|
use sqlx::SqliteConnection;
|
||||||
@@ -43,6 +44,23 @@ pub enum InstanceEffect {
|
|||||||
cap: i64,
|
cap: i64,
|
||||||
default_when_unset: 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 {
|
impl ItemMutation for InstanceEffect {
|
||||||
@@ -63,6 +81,17 @@ impl ItemMutation for InstanceEffect {
|
|||||||
*cap,
|
*cap,
|
||||||
*default_when_unset,
|
*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<Value> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<i64> {
|
async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> {
|
||||||
sqlx::query_scalar::<_, Option<i64>>(
|
sqlx::query_scalar::<_, Option<i64>>(
|
||||||
"SELECT contract_matches FROM owned_cards WHERE id = ?",
|
"SELECT contract_matches FROM owned_cards WHERE id = ?",
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ pub mod settings;
|
|||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod squad_rules;
|
pub mod squad_rules;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
pub mod training;
|
||||||
pub mod upgrades;
|
pub mod upgrades;
|
||||||
|
|||||||
@@ -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<HashMap<String, Vec<TrainingEffect>>> {
|
||||||
|
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<String, Vec<TrainingEffect>> = 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],
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user