8b1081019f
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.
1063 lines
36 KiB
Rust
1063 lines
36 KiB
Rust
//! Atomic "apply one consumable to a target" primitive.
|
|
//!
|
|
//! ONE Core transaction that does, in this order and nothing else:
|
|
//!
|
|
//! 1. validate the SOURCE — it exists, belongs to the club, and is the
|
|
//! `ContentKind` the caller expected;
|
|
//! 2. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned
|
|
//! instance that exists, belongs to the club, and is the expected kind;
|
|
//! 3. write the replay guard — `UNIQUE(profile_id, action_identity)` on
|
|
//! `consumable_applications`, so a duplicate is refused BEFORE anything is
|
|
//! mutated or consumed (same discipline as `match_completions`);
|
|
//! 4. apply the caller's mutation to the target;
|
|
//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
|
//! stack and destroy it at zero;
|
|
//! 6. commit.
|
|
//!
|
|
//! Anything failing at any step rolls the whole thing back: the source is never
|
|
//! spent without the effect landing, and the effect never lands without the
|
|
//! source being spent.
|
|
//!
|
|
//! Core deliberately supplies **no per-category formula**. What a fitness card,
|
|
//! a contract, a chemistry style or a position modifier actually DOES to its
|
|
//! target is the calling game adapter's reversed behaviour, passed in as
|
|
//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the
|
|
//! honest stopping point for one is ownership + projection, i.e. not calling
|
|
//! this function at all.
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
|
|
use chrono::Utc;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use sqlx::SqliteConnection;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
db::Pool,
|
|
error::{AppError, AppResult},
|
|
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
|
};
|
|
|
|
/// What the transaction does to the source instance once the effect is applied.
|
|
///
|
|
/// Both variants run inside the one transaction and under the one replay guard.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SourceConsumption {
|
|
/// Destroy the instance: exactly one `owned_cards` row is DELETEd.
|
|
DestroyInstance,
|
|
/// Decrement a stack by `amount`, destroying the row when it reaches zero.
|
|
///
|
|
/// Only valid for a source that actually carries a stack size
|
|
/// (`owned_cards.quantity IS NOT NULL`); a bare instance has no count to
|
|
/// decrement and is refused rather than silently destroyed.
|
|
DecrementStack { amount: i64 },
|
|
}
|
|
|
|
/// What the consumable is being applied to.
|
|
///
|
|
/// Kind validation is explicit at the call site: the caller states which
|
|
/// `ContentKind` the target must be, because only the caller knows that (say) a
|
|
/// chemistry style goes on a player and a manager-league modifier goes on a
|
|
/// manager.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ConsumeTarget<'a> {
|
|
/// Another owned instance of the same club.
|
|
OwnedCard {
|
|
owned_card_id: &'a str,
|
|
expected_kind: ContentKind,
|
|
},
|
|
/// Club-scoped state rather than an owned instance (the mutation writes
|
|
/// whatever club row it owns; Core validates only the source).
|
|
Club,
|
|
}
|
|
|
|
/// One application request.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ConsumeRequest<'a> {
|
|
/// Opaque, stable per-application token supplied by the caller. Core never
|
|
/// parses it; it only enforces `UNIQUE(profile_id, action_identity)`.
|
|
pub action_identity: &'a str,
|
|
pub source_owned_card_id: &'a str,
|
|
/// The kind the source MUST be. A mismatch is refused.
|
|
pub expected_source_kind: ContentKind,
|
|
pub consumption: SourceConsumption,
|
|
pub target: ConsumeTarget<'a>,
|
|
}
|
|
|
|
/// Validated context handed to the caller's mutation. Both rows are as they were
|
|
/// read inside the transaction, before any mutation or consumption.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConsumeContext {
|
|
pub profile_id: String,
|
|
pub club_id: String,
|
|
pub source: OwnedCard,
|
|
/// `None` for [`ConsumeTarget::Club`].
|
|
pub target: Option<OwnedCard>,
|
|
}
|
|
|
|
/// A future returned by an [`ItemMutation`], borrowing the transaction.
|
|
pub type MutationFuture<'c> = Pin<Box<dyn Future<Output = AppResult<Value>> + Send + 'c>>;
|
|
|
|
/// The caller's effect on the target, applied inside Core's transaction.
|
|
///
|
|
/// It receives the transaction connection, so every write it makes is committed
|
|
/// or rolled back together with the source consumption. The `Value` it returns is
|
|
/// stored verbatim as the application's recorded outcome and echoed on replay —
|
|
/// Core never interprets it.
|
|
pub trait ItemMutation: Send + Sync {
|
|
fn apply<'c>(
|
|
&'c self,
|
|
tx: &'c mut SqliteConnection,
|
|
ctx: &'c ConsumeContext,
|
|
) -> MutationFuture<'c>;
|
|
}
|
|
|
|
impl<F> ItemMutation for F
|
|
where
|
|
F: for<'c> Fn(&'c mut SqliteConnection, &'c ConsumeContext) -> MutationFuture<'c> + Send + Sync,
|
|
{
|
|
fn apply<'c>(
|
|
&'c self,
|
|
tx: &'c mut SqliteConnection,
|
|
ctx: &'c ConsumeContext,
|
|
) -> MutationFuture<'c> {
|
|
self(tx, ctx)
|
|
}
|
|
}
|
|
|
|
/// The outcome of an application (fresh or replayed).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ConsumeOutcome {
|
|
/// `true` when THIS call applied the effect; `false` when it was a replay of
|
|
/// an already-recorded application, which mutated nothing.
|
|
pub applied: bool,
|
|
pub action_identity: String,
|
|
pub source_owned_card_id: String,
|
|
/// `true` when the source instance was destroyed, `false` when a stack was
|
|
/// decremented and survived.
|
|
pub source_destroyed: bool,
|
|
/// Remaining stack size after a decrement; `None` when the instance was
|
|
/// destroyed or carried no stack.
|
|
pub source_quantity_after: Option<i64>,
|
|
pub target_owned_card_id: Option<String>,
|
|
/// The caller mutation's own recorded summary, verbatim.
|
|
pub effect: Value,
|
|
}
|
|
|
|
async fn fetch_owned(
|
|
conn: &mut SqliteConnection,
|
|
owned_card_id: &str,
|
|
club_id: &str,
|
|
) -> AppResult<OwnedCard> {
|
|
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
|
|
.bind(owned_card_id)
|
|
.bind(club_id)
|
|
.fetch_optional(&mut *conn)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))
|
|
}
|
|
|
|
fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResult<()> {
|
|
if card.content_kind != expected {
|
|
return Err(AppError::BadRequest(format!(
|
|
"{role} '{}' is content kind '{}', expected '{expected}'",
|
|
card.id, card.content_kind
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply one consumable to one target, exactly once. See the module docs.
|
|
pub async fn consume_item<M: ItemMutation>(
|
|
pool: &Pool,
|
|
profile_id: &str,
|
|
club_id: &str,
|
|
req: &ConsumeRequest<'_>,
|
|
mutation: &M,
|
|
) -> AppResult<ConsumeOutcome> {
|
|
if req.action_identity.trim().is_empty() {
|
|
return Err(AppError::BadRequest(
|
|
"action_identity must not be empty".into(),
|
|
));
|
|
}
|
|
if let SourceConsumption::DecrementStack { amount } = req.consumption {
|
|
if amount < 1 {
|
|
return Err(AppError::BadRequest(
|
|
"stack decrement amount must be >= 1".into(),
|
|
));
|
|
}
|
|
}
|
|
if let ConsumeTarget::OwnedCard { owned_card_id, .. } = req.target {
|
|
if owned_card_id == req.source_owned_card_id {
|
|
return Err(AppError::BadRequest(
|
|
"a consumable cannot be applied to itself".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut tx = pool.begin().await?;
|
|
|
|
// 1. source: owned by this club, and the kind the caller expected.
|
|
let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?;
|
|
require_kind(&source, req.expected_source_kind, "source item")?;
|
|
|
|
// A source that is fielded in a squad cannot be consumed: `squad_players`
|
|
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
|
|
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
|
// silently evict a lineup as a side effect of spending an item.
|
|
let fielded =
|
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?")
|
|
.bind(req.source_owned_card_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
if fielded > 0 {
|
|
return Err(AppError::Conflict(format!(
|
|
"source item '{}' is fielded in a squad and cannot be consumed",
|
|
req.source_owned_card_id
|
|
)));
|
|
}
|
|
|
|
// 2. target.
|
|
let target = match req.target {
|
|
ConsumeTarget::OwnedCard {
|
|
owned_card_id,
|
|
expected_kind,
|
|
} => {
|
|
let card = fetch_owned(&mut tx, owned_card_id, club_id).await?;
|
|
require_kind(&card, expected_kind, "target item")?;
|
|
Some(card)
|
|
}
|
|
ConsumeTarget::Club => None,
|
|
};
|
|
|
|
// 3. replay guard FIRST — before the mutation and before the consumption, so
|
|
// a duplicate cannot apply a second effect or spend a second charge. The
|
|
// recorded `effect` is filled in below, once the mutation has produced it.
|
|
let application_id = Uuid::new_v4().to_string();
|
|
let now = Utc::now().to_rfc3339();
|
|
let guard = sqlx::query(
|
|
"INSERT INTO consumable_applications \
|
|
(id, profile_id, action_identity, source_owned_card_id, source_card_id, \
|
|
source_content_kind, source_consumed, source_quantity_after, \
|
|
target_owned_card_id, effect, applied_at) \
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)",
|
|
)
|
|
.bind(&application_id)
|
|
.bind(profile_id)
|
|
.bind(req.action_identity)
|
|
.bind(&source.id)
|
|
.bind(&source.card_id)
|
|
.bind(source.content_kind.as_str())
|
|
.bind(target.as_ref().map(|t| t.id.as_str()))
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
match guard {
|
|
Ok(_) => {}
|
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
tx.rollback().await?;
|
|
return already_applied(pool, profile_id, req.action_identity).await;
|
|
}
|
|
Err(e) => {
|
|
tx.rollback().await?;
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
// 4. the caller's effect on the target, inside this transaction.
|
|
let ctx = ConsumeContext {
|
|
profile_id: profile_id.to_string(),
|
|
club_id: club_id.to_string(),
|
|
source,
|
|
target,
|
|
};
|
|
let effect = mutation.apply(&mut tx, &ctx).await?;
|
|
|
|
// 5. consume the source exactly once. Both paths assert rows_affected == 1,
|
|
// so a concurrent spend of the same instance (which lost the SQLite write
|
|
// lock and now sees the row gone / already decremented) fails instead of
|
|
// granting a second effect.
|
|
let (source_destroyed, source_quantity_after) = match req.consumption {
|
|
SourceConsumption::DestroyInstance => {
|
|
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
|
.bind(&ctx.source.id)
|
|
.bind(club_id)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if deleted != 1 {
|
|
return Err(AppError::Conflict(format!(
|
|
"source item '{}' was already consumed",
|
|
ctx.source.id
|
|
)));
|
|
}
|
|
(true, None)
|
|
}
|
|
SourceConsumption::DecrementStack { amount } => {
|
|
let Some(have) = ctx.source.quantity else {
|
|
return Err(AppError::BadRequest(format!(
|
|
"source item '{}' carries no stack size; it can only be destroyed",
|
|
ctx.source.id
|
|
)));
|
|
};
|
|
if have < amount {
|
|
return Err(AppError::Conflict(format!(
|
|
"source item '{}' holds {have}, cannot consume {amount}",
|
|
ctx.source.id
|
|
)));
|
|
}
|
|
let remaining = have - amount;
|
|
if remaining == 0 {
|
|
let deleted = sqlx::query(
|
|
"DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?",
|
|
)
|
|
.bind(&ctx.source.id)
|
|
.bind(club_id)
|
|
.bind(have)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if deleted != 1 {
|
|
return Err(AppError::Conflict(format!(
|
|
"source item '{}' changed under us",
|
|
ctx.source.id
|
|
)));
|
|
}
|
|
(true, None)
|
|
} else {
|
|
let updated = sqlx::query(
|
|
"UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \
|
|
AND quantity = ?",
|
|
)
|
|
.bind(remaining)
|
|
.bind(&ctx.source.id)
|
|
.bind(club_id)
|
|
.bind(have)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if updated != 1 {
|
|
return Err(AppError::Conflict(format!(
|
|
"source item '{}' changed under us",
|
|
ctx.source.id
|
|
)));
|
|
}
|
|
(false, Some(remaining))
|
|
}
|
|
}
|
|
};
|
|
|
|
let effect_text = serde_json::to_string(&effect)?;
|
|
sqlx::query(
|
|
"UPDATE consumable_applications \
|
|
SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?",
|
|
)
|
|
.bind(&effect_text)
|
|
.bind(i64::from(source_destroyed))
|
|
.bind(source_quantity_after)
|
|
.bind(&application_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok(ConsumeOutcome {
|
|
applied: true,
|
|
action_identity: req.action_identity.to_string(),
|
|
source_owned_card_id: ctx.source.id.clone(),
|
|
source_destroyed,
|
|
source_quantity_after,
|
|
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
|
effect,
|
|
})
|
|
}
|
|
|
|
/// Echo the recorded outcome of an application that already happened. Mutates
|
|
/// nothing and reports `applied = false`.
|
|
async fn already_applied(
|
|
pool: &Pool,
|
|
profile_id: &str,
|
|
action_identity: &str,
|
|
) -> AppResult<ConsumeOutcome> {
|
|
let row = sqlx::query_as::<_, (String, i64, Option<i64>, Option<String>, String)>(
|
|
"SELECT source_owned_card_id, source_consumed, source_quantity_after, \
|
|
target_owned_card_id, effect FROM consumable_applications \
|
|
WHERE profile_id = ? AND action_identity = ?",
|
|
)
|
|
.bind(profile_id)
|
|
.bind(action_identity)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
AppError::Internal(anyhow::anyhow!(
|
|
"consumable_applications row missing after unique violation"
|
|
))
|
|
})?;
|
|
let (source_owned_card_id, source_consumed, source_quantity_after, target, effect_text) = row;
|
|
Ok(ConsumeOutcome {
|
|
applied: false,
|
|
action_identity: action_identity.to_string(),
|
|
source_owned_card_id,
|
|
source_destroyed: source_consumed != 0,
|
|
source_quantity_after,
|
|
target_owned_card_id: target,
|
|
// Written by this module as JSON, so a parse failure is corruption, not
|
|
// an expected case — surface it instead of quietly returning null.
|
|
effect: serde_json::from_str(&effect_text)?,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db;
|
|
use serde_json::json;
|
|
|
|
const TS: &str = "2026-01-01T00:00:00Z";
|
|
|
|
/// A file-backed pool (so a "restart" can reopen the same DB) with one club
|
|
/// holding: a stacked consumable, bare consumables, players (one fielded), a
|
|
/// kit, and a consumable owned by ANOTHER club.
|
|
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let url = format!("sqlite://{}", dir.path().join("core.db").display());
|
|
let pool = db::init_pool(&url, 5).await.expect("init pool");
|
|
db::run_migrations(&pool).await.expect("migrations");
|
|
|
|
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
|
|
sqlx::query(
|
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.bind(profile)
|
|
.bind(profile)
|
|
.bind(TS)
|
|
.bind(TS)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("profile");
|
|
sqlx::query(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
|
VALUES (?, ?, ?, 0, ?, ?)",
|
|
)
|
|
.bind(club)
|
|
.bind(profile)
|
|
.bind(club)
|
|
.bind(TS)
|
|
.bind(TS)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("club");
|
|
}
|
|
for (id, club, kind, quantity) in [
|
|
("stack", "club-a", ContentKind::Consumable, Some(15i64)),
|
|
("single", "club-a", ContentKind::Consumable, None),
|
|
("single2", "club-a", ContentKind::Consumable, None),
|
|
("player", "club-a", ContentKind::Player, None),
|
|
("fielded", "club-a", ContentKind::Player, None),
|
|
("kit", "club-a", ContentKind::Kit, None),
|
|
("foreign", "club-b", ContentKind::Consumable, None),
|
|
] {
|
|
sqlx::query(
|
|
"INSERT INTO owned_cards \
|
|
(id, club_id, card_id, is_loan, acquired_at, content_kind, quantity) \
|
|
VALUES (?, ?, ?, 0, ?, ?, ?)",
|
|
)
|
|
.bind(id)
|
|
.bind(club)
|
|
.bind(format!("def-{id}"))
|
|
.bind(TS)
|
|
.bind(kind.as_str())
|
|
.bind(quantity)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("owned card");
|
|
}
|
|
sqlx::query(
|
|
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
|
|
VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)",
|
|
)
|
|
.bind(TS)
|
|
.bind(TS)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("squad");
|
|
sqlx::query(
|
|
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) \
|
|
VALUES ('sp-1', 'sq-a', 'fielded', 0)",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("squad player");
|
|
(dir, url, pool)
|
|
}
|
|
|
|
/// Bumps the target's training bonus — a stand-in for a caller-owned effect.
|
|
/// Core supplies no formula; this one lives entirely in the test.
|
|
struct BumpTraining;
|
|
|
|
impl ItemMutation for BumpTraining {
|
|
fn apply<'c>(
|
|
&'c self,
|
|
tx: &'c mut SqliteConnection,
|
|
ctx: &'c ConsumeContext,
|
|
) -> MutationFuture<'c> {
|
|
Box::pin(async move {
|
|
let target = ctx.target.as_ref().expect("target required");
|
|
sqlx::query(
|
|
"UPDATE owned_cards SET training_bonus = training_bonus + 1 WHERE id = ?",
|
|
)
|
|
.bind(&target.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Ok(json!({ "training_bonus_delta": 1 }))
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A mutation that always fails, to prove the whole transaction unwinds.
|
|
struct Failing;
|
|
|
|
impl ItemMutation for Failing {
|
|
fn apply<'c>(
|
|
&'c self,
|
|
_tx: &'c mut SqliteConnection,
|
|
_ctx: &'c ConsumeContext,
|
|
) -> MutationFuture<'c> {
|
|
Box::pin(async move { Err(AppError::BadRequest("effect refused".into())) })
|
|
}
|
|
}
|
|
|
|
/// Coerces a closure into the higher-ranked shape the blanket [`ItemMutation`]
|
|
/// impl requires — proving a caller can pass an inline effect, not just a
|
|
/// named type.
|
|
fn mutation<F>(f: F) -> F
|
|
where
|
|
F: for<'c> Fn(&'c mut SqliteConnection, &'c ConsumeContext) -> MutationFuture<'c>
|
|
+ Send
|
|
+ Sync,
|
|
{
|
|
f
|
|
}
|
|
|
|
fn req<'a>(
|
|
identity: &'a str,
|
|
source: &'a str,
|
|
consumption: SourceConsumption,
|
|
target: &'a str,
|
|
) -> ConsumeRequest<'a> {
|
|
ConsumeRequest {
|
|
action_identity: identity,
|
|
source_owned_card_id: source,
|
|
expected_source_kind: ContentKind::Consumable,
|
|
consumption,
|
|
target: ConsumeTarget::OwnedCard {
|
|
owned_card_id: target,
|
|
expected_kind: ContentKind::Player,
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn count(pool: &db::Pool, sql: &str) -> i64 {
|
|
sqlx::query_scalar::<_, i64>(sql)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn training(pool: &db::Pool, id: &str) -> i64 {
|
|
sqlx::query_scalar::<_, i64>("SELECT training_bonus FROM owned_cards WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn quantity(pool: &db::Pool, id: &str) -> Option<i64> {
|
|
sqlx::query_scalar::<_, Option<i64>>("SELECT quantity FROM owned_cards WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.unwrap()
|
|
.flatten()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn applies_effect_and_destroys_the_instance() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let out = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"act-1",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect("apply");
|
|
|
|
assert!(out.applied);
|
|
assert!(out.source_destroyed);
|
|
assert_eq!(out.source_quantity_after, None);
|
|
assert_eq!(out.effect, json!({ "training_bonus_delta": 1 }));
|
|
assert_eq!(training(&pool, "player").await, 1, "effect landed");
|
|
assert_eq!(
|
|
count(
|
|
&pool,
|
|
"SELECT COUNT(*) FROM owned_cards WHERE id = 'single'"
|
|
)
|
|
.await,
|
|
0,
|
|
"a consumed card must no longer be owned"
|
|
);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inline_closure_effect_is_accepted() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let effect = mutation(|tx: &mut SqliteConnection, ctx: &ConsumeContext| {
|
|
let target = ctx.target.as_ref().expect("target").id.clone();
|
|
Box::pin(async move {
|
|
sqlx::query("UPDATE owned_cards SET chemistry_style = 'anchor' WHERE id = ?")
|
|
.bind(&target)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Ok(json!({ "chemistry_style": "anchor" }))
|
|
}) as MutationFuture<'_>
|
|
});
|
|
let out = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"act-closure",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
),
|
|
&effect,
|
|
)
|
|
.await
|
|
.expect("apply");
|
|
assert!(out.applied);
|
|
let style = sqlx::query_scalar::<_, String>(
|
|
"SELECT chemistry_style FROM owned_cards WHERE id = 'player'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(style, "anchor");
|
|
}
|
|
|
|
/// The core replay guarantee: the same identity twice = ONE mutation and ONE
|
|
/// charge, with the recorded outcome echoed back as `applied = false`.
|
|
#[tokio::test]
|
|
async fn replay_of_one_identity_mutates_once() {
|
|
let (dir, url, pool) = fixture().await;
|
|
let spend = |identity| {
|
|
req(
|
|
identity,
|
|
"stack",
|
|
SourceConsumption::DecrementStack { amount: 5 },
|
|
"player",
|
|
)
|
|
};
|
|
let first = consume_item(&pool, "prof-a", "club-a", &spend("act-1"), &BumpTraining)
|
|
.await
|
|
.expect("first");
|
|
assert!(first.applied);
|
|
assert_eq!(first.source_quantity_after, Some(10));
|
|
|
|
let replay = consume_item(&pool, "prof-a", "club-a", &spend("act-1"), &BumpTraining)
|
|
.await
|
|
.expect("replay");
|
|
assert!(!replay.applied, "a replay must not re-apply");
|
|
assert_eq!(replay.source_quantity_after, Some(10));
|
|
assert_eq!(replay.effect, json!({ "training_bonus_delta": 1 }));
|
|
assert_eq!(training(&pool, "player").await, 1, "effect applied once");
|
|
assert_eq!(quantity(&pool, "stack").await, Some(10), "charged once");
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
1
|
|
);
|
|
|
|
// RESTART: the guard is durable, not in-memory.
|
|
pool.close().await;
|
|
let reopened = db::init_pool(&url, 5).await.expect("reopen");
|
|
db::run_migrations(&reopened).await.expect("migrations");
|
|
let after_restart = consume_item(
|
|
&reopened,
|
|
"prof-a",
|
|
"club-a",
|
|
&spend("act-1"),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect("restart replay");
|
|
assert!(!after_restart.applied);
|
|
assert_eq!(quantity(&reopened, "stack").await, Some(10));
|
|
assert_eq!(training(&reopened, "player").await, 1);
|
|
drop(dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_duplicates_apply_exactly_once() {
|
|
let (_dir, url, pool) = fixture().await;
|
|
drop(pool);
|
|
let pool = db::init_pool(&url, 8).await.expect("pool");
|
|
|
|
let mut handles = Vec::new();
|
|
for _ in 0..6 {
|
|
let p = pool.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
consume_item(
|
|
&p,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"race",
|
|
"stack",
|
|
SourceConsumption::DecrementStack { amount: 3 },
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
}));
|
|
}
|
|
let mut applied = 0;
|
|
for h in handles {
|
|
if let Ok(Ok(out)) = h.await {
|
|
if out.applied {
|
|
applied += 1;
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(applied, 1, "exactly one racer applies the effect");
|
|
assert_eq!(quantity(&pool, "stack").await, Some(12), "charged once");
|
|
assert_eq!(training(&pool, "player").await, 1);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stack_is_destroyed_when_it_reaches_zero() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let out = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"act-all",
|
|
"stack",
|
|
SourceConsumption::DecrementStack { amount: 15 },
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect("apply");
|
|
assert!(out.source_destroyed);
|
|
assert_eq!(out.source_quantity_after, None);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM owned_cards WHERE id = 'stack'").await,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn overdrawing_a_stack_is_refused_whole() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"act-over",
|
|
"stack",
|
|
SourceConsumption::DecrementStack { amount: 16 },
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect_err("cannot spend more than is held");
|
|
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
|
|
assert_eq!(quantity(&pool, "stack").await, Some(15));
|
|
assert_eq!(training(&pool, "player").await, 0, "effect rolled back");
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
0
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decrementing_a_non_stack_is_refused() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"act-nostack",
|
|
"single",
|
|
SourceConsumption::DecrementStack { amount: 1 },
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect_err("a bare instance has no count to decrement");
|
|
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
|
assert_eq!(
|
|
count(
|
|
&pool,
|
|
"SELECT COUNT(*) FROM owned_cards WHERE id = 'single'"
|
|
)
|
|
.await,
|
|
1,
|
|
"and it must NOT be silently destroyed instead"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failing_effect_rolls_back_the_charge() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let spend = || {
|
|
req(
|
|
"act-fail",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
)
|
|
};
|
|
let err = consume_item(&pool, "prof-a", "club-a", &spend(), &Failing)
|
|
.await
|
|
.expect_err("effect refused");
|
|
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
|
assert_eq!(
|
|
count(
|
|
&pool,
|
|
"SELECT COUNT(*) FROM owned_cards WHERE id = 'single'"
|
|
)
|
|
.await,
|
|
1,
|
|
"the source must survive an unapplied effect"
|
|
);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
0,
|
|
"and the guard must not block a legitimate retry"
|
|
);
|
|
|
|
// The retry with the SAME identity now succeeds, because nothing landed.
|
|
let out = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
|
|
.await
|
|
.expect("retry");
|
|
assert!(out.applied);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validates_ownership_and_kinds_before_anything_moves() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
|
|
// Source owned by another club.
|
|
assert!(consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"v1",
|
|
"foreign",
|
|
SourceConsumption::DestroyInstance,
|
|
"player"
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.is_err());
|
|
|
|
// Source is not the kind the caller expected (a kit is not a consumable).
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req("v2", "kit", SourceConsumption::DestroyInstance, "player"),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect_err("kind mismatch");
|
|
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
|
|
|
// Target is not the kind the caller expected (a kit is not a player).
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req("v3", "single", SourceConsumption::DestroyInstance, "kit"),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect_err("target kind mismatch");
|
|
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
|
|
|
// Target owned by another club.
|
|
assert!(consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"v4",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"foreign"
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.is_err());
|
|
|
|
// Applying an item to itself.
|
|
assert!(consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req("v5", "single", SourceConsumption::DestroyInstance, "single"),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.is_err());
|
|
|
|
// An empty identity has no replay identity at all.
|
|
assert!(consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(" ", "single", SourceConsumption::DestroyInstance, "player"),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.is_err());
|
|
|
|
assert_eq!(count(&pool, "SELECT COUNT(*) FROM owned_cards").await, 7);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
0
|
|
);
|
|
assert_eq!(training(&pool, "player").await, 0);
|
|
}
|
|
|
|
/// A source fielded in a squad is refused explicitly — never destroyed, and
|
|
/// never silently evicted from the lineup as a side effect.
|
|
#[tokio::test]
|
|
async fn a_fielded_source_cannot_be_consumed() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&ConsumeRequest {
|
|
action_identity: "act-fielded",
|
|
source_owned_card_id: "fielded",
|
|
expected_source_kind: ContentKind::Player,
|
|
consumption: SourceConsumption::DestroyInstance,
|
|
target: ConsumeTarget::Club,
|
|
},
|
|
&mutation(|_tx: &mut SqliteConnection, _ctx: &ConsumeContext| {
|
|
Box::pin(async move { Ok(Value::Null) }) as MutationFuture<'_>
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("a fielded item cannot be consumed");
|
|
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
|
|
assert_eq!(count(&pool, "SELECT COUNT(*) FROM squad_players").await, 1);
|
|
assert_eq!(
|
|
count(
|
|
&pool,
|
|
"SELECT COUNT(*) FROM owned_cards WHERE id = 'fielded'"
|
|
)
|
|
.await,
|
|
1
|
|
);
|
|
}
|
|
|
|
/// Lifecycle invariant: once consumed, the instance is gone — a second spend
|
|
/// under a DIFFERENT identity cannot resurrect it.
|
|
#[tokio::test]
|
|
async fn a_consumed_instance_cannot_be_spent_again() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"first",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect("first spend");
|
|
|
|
let err = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
"second",
|
|
"single",
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect_err("a consumed instance is no longer owned");
|
|
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
|
|
assert_eq!(training(&pool, "player").await, 1, "effect applied once");
|
|
}
|
|
|
|
/// Two DISTINCT instances of the same definition are two separate charges —
|
|
/// the real profile owns exactly that shape (two copies of one resourceId),
|
|
/// so consuming one must leave the other spendable.
|
|
#[tokio::test]
|
|
async fn two_instances_of_one_definition_are_two_charges() {
|
|
let (_dir, _url, pool) = fixture().await;
|
|
for (identity, source) in [("i1", "single"), ("i2", "single2")] {
|
|
let out = consume_item(
|
|
&pool,
|
|
"prof-a",
|
|
"club-a",
|
|
&req(
|
|
identity,
|
|
source,
|
|
SourceConsumption::DestroyInstance,
|
|
"player",
|
|
),
|
|
&BumpTraining,
|
|
)
|
|
.await
|
|
.expect("spend");
|
|
assert!(out.applied);
|
|
}
|
|
assert_eq!(training(&pool, "player").await, 2);
|
|
assert_eq!(
|
|
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
|
2
|
|
);
|
|
}
|
|
}
|