feat(consume): durable per-instance contract state + HTTP apply route
CI / Build, lint & test (push) Successful in 3m16s
CI / Build, lint & test (push) Successful in 3m16s
`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.
Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.
The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.
Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.
Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
discipline economy.rs documents: three reads precede the first write, which
is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
first apply deletes the source, so the old order answered a retry with 404
instead of the recorded outcome -- replay semantics were unreachable.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
-- Per-instance match-contract counter on an owned instance.
|
||||
--
|
||||
-- NULLABLE ON PURPOSE. NULL means "Core tracks no contract for this instance",
|
||||
-- which is NOT the same as zero: a game whose contracts start at a pack-fresh
|
||||
-- default (FIFA 17 hands out 7) must supply that default itself, so the number
|
||||
-- stays in the game adapter and never becomes a Core constant. Every row that
|
||||
-- pre-dates this migration therefore reads back NULL and keeps its exact prior
|
||||
-- meaning — the migration is a pure widening, not a backfill.
|
||||
--
|
||||
-- `>= 0` only: the cap is a per-application input (the caller's game rule), not
|
||||
-- a schema invariant, so the CHECK refuses the one value that is nonsense in
|
||||
-- every game rather than pinning someone else's ceiling.
|
||||
--
|
||||
-- ALTER TABLE ADD COLUMN, NEVER a table rebuild: `owned_cards` carries the
|
||||
-- `clear_club_active_item_before_transfer` trigger installed by 0026, and a
|
||||
-- DROP/recreate would silently take it with it — exactly the failure 0026:44-46
|
||||
-- documents for 0024's trigger.
|
||||
ALTER TABLE owned_cards ADD COLUMN contract_matches INTEGER
|
||||
CHECK (contract_matches IS NULL OR contract_matches >= 0);
|
||||
@@ -192,6 +192,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
post(routes::economy::post_redeem_entitlement),
|
||||
)
|
||||
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||
.route(
|
||||
"/consumables/apply",
|
||||
post(routes::consumables::post_apply_consumable),
|
||||
)
|
||||
.route(
|
||||
"/economy/grant-reward",
|
||||
post(routes::economy::post_grant_reward),
|
||||
|
||||
+5
-1
@@ -252,6 +252,10 @@ pub struct OwnedCard {
|
||||
pub training_bonus: i64,
|
||||
pub content_kind: ContentKind,
|
||||
pub quantity: Option<i64>,
|
||||
/// Match-contracts remaining on this instance, or `None` when Core tracks
|
||||
/// no contract for it. `None` is not zero: the pack-fresh starting value is
|
||||
/// a per-game rule the caller supplies, never a Core default.
|
||||
pub contract_matches: Option<i64>,
|
||||
}
|
||||
|
||||
/// The ONE canonical column list for reading an [`OwnedCard`].
|
||||
@@ -262,7 +266,7 @@ pub struct OwnedCard {
|
||||
/// never leave a stale SELECT behind; append `WHERE …` to it.
|
||||
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
||||
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
|
||||
training_bonus, content_kind, quantity FROM owned_cards";
|
||||
training_bonus, content_kind, quantity, contract_matches FROM owned_cards";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -151,6 +151,10 @@ pub async fn get_collection(
|
||||
"chemistry_style": o.chemistry_style,
|
||||
"position_override": o.position_override,
|
||||
"training_bonus": o.training_bonus,
|
||||
// Core's stored value verbatim: `null` means Core tracks no contract
|
||||
// for this instance, which is NOT zero. Substituting a default here
|
||||
// would bake one game's pack-fresh number into every game's envelope.
|
||||
"contract_matches": o.contract_matches,
|
||||
"effective_overall": effective_overall,
|
||||
"effective_position": effective_position,
|
||||
"card": def,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `POST /consumables/apply` — the HTTP boundary for Core's atomic
|
||||
//! apply-one-consumable transaction.
|
||||
//!
|
||||
//! Game-neutral like the rest of Core's surface: the caller names an owned source
|
||||
//! instance, an owned target and a described [`InstanceEffect`]; Core resolves the
|
||||
//! game-scoped active profile and its club from the `X-OpenFUT-Game` header, so no
|
||||
//! caller can reach across clubs. Everything after that is one durable SQLite
|
||||
//! transaction in [`consume::consume_item`], guarded by
|
||||
//! `UNIQUE(profile_id, action_identity)`.
|
||||
//!
|
||||
//! The effect vocabulary is closed and validated by Core — see
|
||||
//! [`crate::services::instance_effect`] for why the host describes an effect
|
||||
//! instead of supplying one.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
extractors::GameId,
|
||||
models::card::ContentKind,
|
||||
services::{
|
||||
club as club_svc,
|
||||
consume::{self, ConsumeOutcome, ConsumeRequest, ConsumeTarget, SourceConsumption},
|
||||
instance_effect::InstanceEffect,
|
||||
profile as profile_svc,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyConsumableRequest {
|
||||
/// Opaque, stable per-application token. Core never parses it; it only
|
||||
/// enforces uniqueness, so a retried HTTP request replays instead of
|
||||
/// applying twice.
|
||||
pub action_identity: String,
|
||||
pub source_owned_card_id: String,
|
||||
pub target_owned_card_id: String,
|
||||
/// The kind the target MUST be. The caller states it because only the caller
|
||||
/// knows which family its consumable belongs to; a mismatch is refused rather
|
||||
/// than applied to whatever happens to be there.
|
||||
pub target_kind: ContentKind,
|
||||
pub effect: InstanceEffect,
|
||||
}
|
||||
|
||||
/// `POST /consumables/apply` — atomic validate + apply + consume-once.
|
||||
///
|
||||
/// `applied: false` in the response means the `action_identity` was already
|
||||
/// recorded: nothing was mutated and the recorded outcome is echoed.
|
||||
pub async fn post_apply_consumable(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ApplyConsumableRequest>,
|
||||
) -> AppResult<Json<ConsumeOutcome>> {
|
||||
// Both ids are needed: the profile scopes the replay guard, the club scopes
|
||||
// ownership. Same resolution pair as `cards::get_collection`.
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let outcome = consume::consume_item(
|
||||
&state.pool,
|
||||
&profile.id,
|
||||
&club.id,
|
||||
&ConsumeRequest {
|
||||
action_identity: &req.action_identity,
|
||||
source_owned_card_id: &req.source_owned_card_id,
|
||||
// A consumable is the only thing that can be applied, and it is spent
|
||||
// whole: FIFA-style stacking is the adapter's projection, not an
|
||||
// ownership model Core has for these instances.
|
||||
expected_source_kind: ContentKind::Consumable,
|
||||
consumption: SourceConsumption::DestroyInstance,
|
||||
target: ConsumeTarget::OwnedCard {
|
||||
owned_card_id: &req.target_owned_card_id,
|
||||
expected_kind: req.target_kind,
|
||||
},
|
||||
},
|
||||
&req.effect,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod achievements;
|
||||
pub mod auth;
|
||||
pub mod cards;
|
||||
pub mod club;
|
||||
pub mod consumables;
|
||||
pub mod division;
|
||||
pub mod draft;
|
||||
pub mod economy;
|
||||
|
||||
+262
-166
@@ -2,17 +2,24 @@
|
||||
//!
|
||||
//! ONE Core transaction that does, in this order and nothing else:
|
||||
//!
|
||||
//! 1. validate the SOURCE — it exists, belongs to the club, and is the
|
||||
//! 1. answer a REPLAY — if `(profile_id, action_identity)` is already recorded,
|
||||
//! echo that outcome and touch nothing. This precedes every validation on
|
||||
//! purpose: a completed application has already DESTROYED its source, so
|
||||
//! checking the source first would answer "not found" to a retried request
|
||||
//! that in fact succeeded;
|
||||
//! 2. 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
|
||||
//! 3. 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
|
||||
//! 4. 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
|
||||
//! mutated or consumed (same discipline as `match_completions`). Step 1 is
|
||||
//! a courtesy; THIS is the guarantee, and it holds against a writer on any
|
||||
//! other connection or process;
|
||||
//! 5. apply the caller's mutation to the target;
|
||||
//! 6. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
||||
//! stack and destroy it at zero;
|
||||
//! 6. commit.
|
||||
//! 7. 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
|
||||
@@ -21,9 +28,10 @@
|
||||
//! 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.
|
||||
//! [`ItemMutation`] — either in-process, or described over the wire through the
|
||||
//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. 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;
|
||||
@@ -38,6 +46,7 @@ use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||
services::economy,
|
||||
};
|
||||
|
||||
/// What the transaction does to the source instance once the effect is applied.
|
||||
@@ -169,6 +178,16 @@ fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this call did the work or found the identity already recorded.
|
||||
///
|
||||
/// The replay branch cannot read the recorded outcome while the transaction is
|
||||
/// still open on this connection, so it is reported out of the transaction and
|
||||
/// answered once the connection is free again.
|
||||
enum Applied {
|
||||
Fresh(ConsumeOutcome),
|
||||
Replay,
|
||||
}
|
||||
|
||||
/// Apply one consumable to one target, exactly once. See the module docs.
|
||||
pub async fn consume_item<M: ItemMutation>(
|
||||
pool: &Pool,
|
||||
@@ -197,187 +216,231 @@ pub async fn consume_item<M: ItemMutation>(
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
// ONE connection with an explicit BEGIN IMMEDIATE, never a DEFERRED
|
||||
// `pool.begin()`: this transaction performs three reads before its first
|
||||
// write, and a deferred transaction only upgrades to a write at that first
|
||||
// write — where SQLite answers SQLITE_BUSY *immediately*, bypassing
|
||||
// `busy_timeout` (the full reasoning is on `economy::finish`). Two clients
|
||||
// applying consumables at once is ordinary traffic, so take the write lock
|
||||
// up front and let a rival wait instead of fail.
|
||||
let mut conn = pool.acquire().await?;
|
||||
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).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)
|
||||
let result = async {
|
||||
// 1. a replay is answered before anything is validated: a completed
|
||||
// application has already destroyed (or drawn down) its source, so
|
||||
// validating first would answer NotFound to a retry of a request that
|
||||
// actually succeeded. The write lock is already held, so this read
|
||||
// cannot race the guard INSERT below — which stays as the real
|
||||
// guarantee for a writer on any other connection.
|
||||
if recorded(&mut conn, profile_id, req.action_identity).await? {
|
||||
return Ok(Applied::Replay);
|
||||
}
|
||||
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;
|
||||
// 2. source: owned by this club, and the kind the caller expected.
|
||||
let source = fetch_owned(&mut conn, 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 *conn)
|
||||
.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
|
||||
)));
|
||||
}
|
||||
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
|
||||
)));
|
||||
// 3. target.
|
||||
let target = match req.target {
|
||||
ConsumeTarget::OwnedCard {
|
||||
owned_card_id,
|
||||
expected_kind,
|
||||
} => {
|
||||
let card = fetch_owned(&mut conn, owned_card_id, club_id).await?;
|
||||
require_kind(&card, expected_kind, "target item")?;
|
||||
Some(card)
|
||||
}
|
||||
(true, None)
|
||||
ConsumeTarget::Club => None,
|
||||
};
|
||||
|
||||
// 4. replay guard — the durable one. It precedes the mutation and the
|
||||
// consumption, so a duplicate that got past step 1 on another
|
||||
// connection still 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 *conn)
|
||||
.await;
|
||||
match guard {
|
||||
Ok(_) => {}
|
||||
// A collision writes nothing, so this transaction has nothing to undo
|
||||
// and simply ends; the recorded outcome is read back afterwards.
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
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();
|
||||
|
||||
// 5. 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 conn, &ctx).await?;
|
||||
|
||||
// 6. 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 *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if deleted != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' changed under us",
|
||||
"source item '{}' was already consumed",
|
||||
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 {
|
||||
}
|
||||
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 '{}' changed under us",
|
||||
"source item '{}' holds {have}, cannot consume {amount}",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
(false, Some(remaining))
|
||||
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 *conn)
|
||||
.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 *conn)
|
||||
.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 = ?",
|
||||
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 *conn)
|
||||
.await?;
|
||||
|
||||
Ok(Applied::Fresh(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,
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
|
||||
match economy::finish(&mut conn, result).await? {
|
||||
Applied::Fresh(outcome) => Ok(outcome),
|
||||
// Read the recorded outcome on the SAME connection: acquiring a second one
|
||||
// while still holding this one deadlocks a pool saturated with racing
|
||||
// appliers, which is exactly the case a replay shows up in.
|
||||
Applied::Replay => already_applied(&mut conn, profile_id, req.action_identity).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this identity already been applied? Read inside the transaction, under
|
||||
/// the write lock, so the answer cannot go stale before the guard INSERT.
|
||||
async fn recorded(
|
||||
conn: &mut SqliteConnection,
|
||||
profile_id: &str,
|
||||
action_identity: &str,
|
||||
) -> AppResult<bool> {
|
||||
let hits = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM consumable_applications \
|
||||
WHERE profile_id = ? AND action_identity = ?",
|
||||
)
|
||||
.bind(&effect_text)
|
||||
.bind(i64::from(source_destroyed))
|
||||
.bind(source_quantity_after)
|
||||
.bind(&application_id)
|
||||
.execute(&mut *tx)
|
||||
.bind(profile_id)
|
||||
.bind(action_identity)
|
||||
.fetch_one(&mut *conn)
|
||||
.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,
|
||||
})
|
||||
Ok(hits > 0)
|
||||
}
|
||||
|
||||
/// Echo the recorded outcome of an application that already happened. Mutates
|
||||
/// nothing and reports `applied = false`.
|
||||
async fn already_applied(
|
||||
pool: &Pool,
|
||||
conn: &mut SqliteConnection,
|
||||
profile_id: &str,
|
||||
action_identity: &str,
|
||||
) -> AppResult<ConsumeOutcome> {
|
||||
@@ -388,7 +451,7 @@ async fn already_applied(
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(action_identity)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
@@ -621,6 +684,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A retry of a request that ALREADY succeeded must replay, not 404. With
|
||||
/// `DestroyInstance` the source no longer exists by then, so answering the
|
||||
/// replay has to precede source validation — otherwise a client that lost the
|
||||
/// response to a successful apply is told its item was never there.
|
||||
#[tokio::test]
|
||||
async fn a_replay_survives_the_source_it_destroyed() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
let spend = || {
|
||||
req(
|
||||
"act-gone",
|
||||
"single",
|
||||
SourceConsumption::DestroyInstance,
|
||||
"player",
|
||||
)
|
||||
};
|
||||
let first = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
|
||||
.await
|
||||
.expect("first");
|
||||
assert!(first.applied);
|
||||
|
||||
let replay = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
|
||||
.await
|
||||
.expect("a retry must replay, not fail on the destroyed source");
|
||||
assert!(!replay.applied);
|
||||
assert!(replay.source_destroyed);
|
||||
assert_eq!(replay.effect, first.effect);
|
||||
assert_eq!(training(&pool, "player").await, 1, "effect applied once");
|
||||
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;
|
||||
|
||||
@@ -283,7 +283,7 @@ pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult
|
||||
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||
/// deadlock) — the fresh-DB multi-connection write failure.
|
||||
async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
|
||||
pub(crate) async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
|
||||
match result {
|
||||
Ok(v) => {
|
||||
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! The CLOSED vocabulary of instance mutations Core can execute for a caller
|
||||
//! that is not in-process.
|
||||
//!
|
||||
//! [`crate::services::consume::consume_item`] takes an
|
||||
//! [`ItemMutation`] — an in-process closure, which cannot cross an HTTP
|
||||
//! boundary. A game host applying a consumable over HTTP therefore cannot SUPPLY
|
||||
//! its effect; it can only DESCRIBE one, and Core executes the description.
|
||||
//!
|
||||
//! That does not move the game's formula into Core. The caller still owns every
|
||||
//! number: how many match-contracts a given card grants a given target is the
|
||||
//! adapter's reversed per-game table, and it arrives here as `amount`. Core owns
|
||||
//! only what it can prove without knowing the game — the arithmetic, the clamp,
|
||||
//! the ownership scope, the loan invariant and the transaction. This is the same
|
||||
//! split quick-sell already uses (the host prices the item, Core moves it).
|
||||
//!
|
||||
//! The enum is closed ON PURPOSE. A generic "set field X to value Y" escape
|
||||
//! hatch would hand the host arbitrary write access to Core state and make every
|
||||
//! present and future invariant unenforceable; a new effect is a new variant,
|
||||
//! validated here, reviewed here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::card::OwnedCard,
|
||||
services::consume::{ConsumeContext, ItemMutation, MutationFuture},
|
||||
};
|
||||
|
||||
/// One wire-describable mutation of a target instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum InstanceEffect {
|
||||
/// Add match-contracts to the target, saturating at `cap`.
|
||||
///
|
||||
/// `default_when_unset` is what a target Core tracks no contract for counts
|
||||
/// as — the caller's pack-fresh starting value (FIFA 17: 7). Core has no such
|
||||
/// default of its own, which is precisely why `owned_cards.contract_matches`
|
||||
/// is nullable; see migration 0028.
|
||||
AddContractMatches {
|
||||
amount: i64,
|
||||
cap: i64,
|
||||
default_when_unset: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl ItemMutation for InstanceEffect {
|
||||
fn apply<'c>(
|
||||
&'c self,
|
||||
tx: &'c mut SqliteConnection,
|
||||
ctx: &'c ConsumeContext,
|
||||
) -> MutationFuture<'c> {
|
||||
match self {
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount,
|
||||
cap,
|
||||
default_when_unset,
|
||||
} => Box::pin(add_contract_matches(
|
||||
tx,
|
||||
ctx,
|
||||
*amount,
|
||||
*cap,
|
||||
*default_when_unset,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The target instance this effect mutates. An effect that writes to an instance
|
||||
/// has nothing to write to when the application is club-scoped, so that is a
|
||||
/// refusal rather than a silent no-op that still spends the source.
|
||||
fn require_target<'a>(ctx: &'a ConsumeContext, effect: &str) -> AppResult<&'a OwnedCard> {
|
||||
ctx.target
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::BadRequest(format!("effect '{effect}' requires a target item")))
|
||||
}
|
||||
|
||||
async fn add_contract_matches(
|
||||
tx: &mut SqliteConnection,
|
||||
ctx: &ConsumeContext,
|
||||
amount: i64,
|
||||
cap: i64,
|
||||
default_when_unset: i64,
|
||||
) -> AppResult<Value> {
|
||||
let target = require_target(ctx, "add_contract_matches")?;
|
||||
|
||||
if amount < 1 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches amount must be >= 1, got {amount}"
|
||||
)));
|
||||
}
|
||||
if cap < 1 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches cap must be >= 1, got {cap}"
|
||||
)));
|
||||
}
|
||||
if default_when_unset < 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"add_contract_matches default_when_unset must be >= 0, got {default_when_unset}"
|
||||
)));
|
||||
}
|
||||
// A loan item is borrowed for a fixed number of matches
|
||||
// (`loan_matches_remaining`); topping up its contract would pretend to extend
|
||||
// something Core does not own. Core models loans, so the invariant is Core's.
|
||||
if target.is_loan {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"contracts cannot be applied to a loan item ('{}')",
|
||||
target.id
|
||||
)));
|
||||
}
|
||||
|
||||
// Read-modify-write INSIDE the caller's transaction, deliberately re-reading
|
||||
// rather than trusting the snapshot in `ctx`: the read and the write then sit
|
||||
// in one contiguous critical section under the same write lock, so two
|
||||
// concurrent applies serialise instead of both computing from one `before`
|
||||
// and losing an update.
|
||||
let before = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COALESCE(contract_matches, ?) FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(default_when_unset)
|
||||
.bind(&target.id)
|
||||
.bind(&ctx.club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("target item '{}' not found", target.id)))?;
|
||||
|
||||
// Saturating, because `before + amount` is caller-supplied arithmetic and an
|
||||
// overflow must not panic a request thread; the clamp makes the sum's exact
|
||||
// magnitude irrelevant anyway.
|
||||
let after = before.saturating_add(amount).min(cap);
|
||||
|
||||
let updated =
|
||||
sqlx::query("UPDATE owned_cards SET contract_matches = ? WHERE id = ? AND club_id = ?")
|
||||
.bind(after)
|
||||
.bind(&target.id)
|
||||
.bind(&ctx.club_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if updated != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"target item '{}' changed under us",
|
||||
target.id
|
||||
)));
|
||||
}
|
||||
|
||||
// `granted` is what the caller's table awarded, NOT `after - before`: the cap
|
||||
// can swallow part of it, and the two numbers answer different questions
|
||||
// (what the card was worth vs. what the instance now holds).
|
||||
Ok(json!({
|
||||
"kind": "add_contract_matches",
|
||||
"granted": amount,
|
||||
"before": before,
|
||||
"after": after,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use crate::models::card::ContentKind;
|
||||
use crate::services::consume::{
|
||||
consume_item, ConsumeRequest, ConsumeTarget, SourceConsumption,
|
||||
};
|
||||
|
||||
const TS: &str = "2026-01-01T00:00:00Z";
|
||||
|
||||
/// One club holding contract consumables and three player targets: one Core
|
||||
/// tracks no contract for, one part-way through its contract, and one on loan.
|
||||
async fn fixture() -> (tempfile::TempDir, 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");
|
||||
|
||||
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?,?,?,?)")
|
||||
.bind("prof")
|
||||
.bind("prof")
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("profile");
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('club', 'prof', 'club', 0, ?, ?)",
|
||||
)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("club");
|
||||
|
||||
for (id, kind, is_loan, contract) in [
|
||||
("card-1", ContentKind::Consumable, 0, None),
|
||||
("card-2", ContentKind::Consumable, 0, None),
|
||||
("card-3", ContentKind::Consumable, 0, None),
|
||||
("fresh", ContentKind::Player, 0, None),
|
||||
("used", ContentKind::Player, 0, Some(90i64)),
|
||||
("loaned", ContentKind::Player, 1, None),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards \
|
||||
(id, club_id, card_id, is_loan, acquired_at, content_kind, contract_matches) \
|
||||
VALUES (?, 'club', ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(format!("def-{id}"))
|
||||
.bind(is_loan)
|
||||
.bind(TS)
|
||||
.bind(kind.as_str())
|
||||
.bind(contract)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("owned card");
|
||||
}
|
||||
(dir, pool)
|
||||
}
|
||||
|
||||
fn apply_request<'a>(
|
||||
identity: &'a str,
|
||||
source: &'a str,
|
||||
target: &'a str,
|
||||
) -> ConsumeRequest<'a> {
|
||||
ConsumeRequest {
|
||||
action_identity: identity,
|
||||
source_owned_card_id: source,
|
||||
expected_source_kind: ContentKind::Consumable,
|
||||
consumption: SourceConsumption::DestroyInstance,
|
||||
target: ConsumeTarget::OwnedCard {
|
||||
owned_card_id: target,
|
||||
expected_kind: ContentKind::Player,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn grant(amount: i64) -> InstanceEffect {
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
}
|
||||
}
|
||||
|
||||
async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> {
|
||||
sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT contract_matches FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("read contract")
|
||||
}
|
||||
|
||||
/// A target Core tracks no contract for counts as the CALLER's pack-fresh
|
||||
/// default, not as zero — the whole reason the column is nullable.
|
||||
#[tokio::test]
|
||||
async fn an_unset_target_starts_from_the_callers_default() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-1", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
|
||||
assert!(out.applied);
|
||||
assert!(out.source_destroyed);
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "fresh").await, Some(22));
|
||||
}
|
||||
|
||||
/// A stored value is added to, never replaced by the default.
|
||||
#[tokio::test]
|
||||
async fn an_existing_value_is_added_to() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-2", "card-1", "used"),
|
||||
&grant(3),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 3, "before": 90, "after": 93 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "used").await, Some(93));
|
||||
}
|
||||
|
||||
/// The cap clamps the STORED total but not the REPORTED grant: they answer
|
||||
/// different questions, and a caller reconciling its own wire response needs
|
||||
/// the amount its table awarded.
|
||||
#[tokio::test]
|
||||
async fn the_cap_clamps_the_total_but_not_the_reported_grant() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let out = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-3", "card-1", "used"),
|
||||
&grant(28),
|
||||
)
|
||||
.await
|
||||
.expect("apply");
|
||||
assert_eq!(
|
||||
out.effect,
|
||||
json!({ "kind": "add_contract_matches", "granted": 28, "before": 90, "after": 99 })
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "used").await, Some(99));
|
||||
}
|
||||
|
||||
/// A loan is borrowed for a fixed run of matches; its contract is not Core's
|
||||
/// to extend. The refusal must also unwind the charge.
|
||||
#[tokio::test]
|
||||
async fn a_loan_target_is_refused_and_the_source_survives() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let err = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-loan", "card-1", "loaned"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect_err("a loan cannot take a contract");
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
||||
assert_eq!(contract_of(&pool, "loaned").await, None);
|
||||
let sources =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = 'card-1'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sources, 1, "a refused effect must not spend the source");
|
||||
}
|
||||
|
||||
/// This effect writes to an instance, so a club-scoped application has
|
||||
/// nothing to write to — refuse rather than spend the source for nothing.
|
||||
#[tokio::test]
|
||||
async fn a_club_scoped_application_is_refused() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let err = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&ConsumeRequest {
|
||||
action_identity: "act-club",
|
||||
source_owned_card_id: "card-1",
|
||||
expected_source_kind: ContentKind::Consumable,
|
||||
consumption: SourceConsumption::DestroyInstance,
|
||||
target: ConsumeTarget::Club,
|
||||
},
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect_err("no target to contract");
|
||||
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Core validates the caller's numbers instead of trusting them: a zero or
|
||||
/// negative grant, a zero cap and a negative default are all nonsense, and a
|
||||
/// nonsense application must not silently succeed as a no-op.
|
||||
#[tokio::test]
|
||||
async fn nonsensical_parameters_are_refused() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
for (identity, effect) in [
|
||||
(
|
||||
"bad-amount-0",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 0,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-amount-neg",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: -5,
|
||||
cap: 99,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-cap",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 1,
|
||||
cap: 0,
|
||||
default_when_unset: 7,
|
||||
},
|
||||
),
|
||||
(
|
||||
"bad-default",
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 1,
|
||||
cap: 99,
|
||||
default_when_unset: -1,
|
||||
},
|
||||
),
|
||||
] {
|
||||
let Err(err) = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request(identity, "card-1", "fresh"),
|
||||
&effect,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("{identity} must be refused");
|
||||
};
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"{identity}: got {err:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
contract_of(&pool, "fresh").await,
|
||||
None,
|
||||
"a refused application leaves the target untracked"
|
||||
);
|
||||
}
|
||||
|
||||
/// The replay guard covers the effect too: a repeated `action_identity`
|
||||
/// echoes the recorded outcome, adds no second grant, and spends no second
|
||||
/// card.
|
||||
#[tokio::test]
|
||||
async fn a_replay_neither_grants_nor_charges_twice() {
|
||||
let (_dir, pool) = fixture().await;
|
||||
let first = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-replay", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("first");
|
||||
assert!(first.applied);
|
||||
|
||||
let replay = consume_item(
|
||||
&pool,
|
||||
"prof",
|
||||
"club",
|
||||
&apply_request("act-replay", "card-1", "fresh"),
|
||||
&grant(15),
|
||||
)
|
||||
.await
|
||||
.expect("replay");
|
||||
assert!(!replay.applied, "a replay must not re-apply");
|
||||
assert_eq!(
|
||||
replay.effect, first.effect,
|
||||
"the recorded outcome is echoed"
|
||||
);
|
||||
assert_eq!(contract_of(&pool, "fresh").await, Some(22), "granted once");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id LIKE 'card-%'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
2,
|
||||
"exactly one consumable was spent"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wire vocabulary is closed: the documented body parses, and an
|
||||
/// undescribed effect is rejected at the boundary rather than reaching a
|
||||
/// fallback.
|
||||
#[test]
|
||||
fn the_effect_vocabulary_is_closed() {
|
||||
let parsed: InstanceEffect = serde_json::from_str(
|
||||
r#"{"kind":"add_contract_matches","amount":15,"cap":99,"default_when_unset":7}"#,
|
||||
)
|
||||
.expect("the documented effect body must parse");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
InstanceEffect::AddContractMatches {
|
||||
amount: 15,
|
||||
cap: 99,
|
||||
default_when_unset: 7
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_str::<InstanceEffect>(r#"{"kind":"set_rating","value":99}"#).is_err(),
|
||||
"an effect Core does not implement must be refused, never guessed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod game_ext;
|
||||
pub mod import;
|
||||
pub mod instance_effect;
|
||||
pub mod inventory;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
|
||||
@@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() {
|
||||
assert_eq!(coins_after, coins_before + coins_received);
|
||||
}
|
||||
|
||||
/// `POST /consumables/apply` end to end, in the exact wire shape a game host
|
||||
/// sends: destroy the consumable, move the target's contract counter, surface it
|
||||
/// on `/collection`, and REPLAY (not re-apply) a retried request.
|
||||
///
|
||||
/// Built on its own pool so a consumable instance can be minted directly — the
|
||||
/// starter packs only yield players, and Core has no route that creates one.
|
||||
#[tokio::test]
|
||||
async fn test_apply_contract_consumable_over_http() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations");
|
||||
let app = openfut_core::build_app(pool.clone(), "data")
|
||||
.await
|
||||
.expect("app build");
|
||||
auth(&app, "ContractApplier").await;
|
||||
|
||||
let (_, packs) = json_get(&app, "/packs").await;
|
||||
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||
let (s, _) = json_post(
|
||||
&app,
|
||||
&format!("/packs/open/{pack_id}"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
let target = coll["collection"][0].clone();
|
||||
let target_id = target["owned_card_id"].as_str().unwrap().to_string();
|
||||
let card_id = target["card"]["id"].as_str().unwrap().to_string();
|
||||
assert!(
|
||||
target["contract_matches"].is_null(),
|
||||
"a pack-fresh instance must report NULL, not a substituted default"
|
||||
);
|
||||
|
||||
// Mint the consumable into the target's own club, reusing a definition the
|
||||
// content pack already loaded so `/collection` can still project it.
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
|
||||
SELECT 'contract-card', club_id, ?, 0, ?, 'consumable' FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(&card_id)
|
||||
.bind("2026-01-01T00:00:00Z")
|
||||
.bind(&target_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("mint a consumable");
|
||||
|
||||
let request = serde_json::json!({
|
||||
"action_identity": format!("fifa17:apply:contract-card->{target_id}"),
|
||||
"source_owned_card_id": "contract-card",
|
||||
"target_owned_card_id": target_id,
|
||||
"target_kind": "player",
|
||||
"effect": {
|
||||
"kind": "add_contract_matches",
|
||||
"amount": 15,
|
||||
"cap": 99,
|
||||
"default_when_unset": 7,
|
||||
},
|
||||
});
|
||||
let (s, applied) = json_post(&app, "/consumables/apply", request.clone()).await;
|
||||
assert_eq!(s, StatusCode::OK, "{applied}");
|
||||
assert_eq!(applied["applied"], serde_json::json!(true));
|
||||
assert_eq!(applied["source_destroyed"], serde_json::json!(true));
|
||||
assert!(applied["source_quantity_after"].is_null());
|
||||
assert_eq!(
|
||||
applied["target_owned_card_id"],
|
||||
serde_json::json!(target_id)
|
||||
);
|
||||
assert_eq!(
|
||||
applied["effect"],
|
||||
serde_json::json!({
|
||||
"kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22
|
||||
})
|
||||
);
|
||||
|
||||
let (_, after) = json_get(&app, "/collection").await;
|
||||
let items = after["collection"].as_array().unwrap();
|
||||
let projected = items
|
||||
.iter()
|
||||
.find(|c| c["owned_card_id"] == serde_json::json!(target_id))
|
||||
.expect("target still owned");
|
||||
assert_eq!(projected["contract_matches"], serde_json::json!(22));
|
||||
assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|c| c["owned_card_id"] == serde_json::json!("contract-card")),
|
||||
"the consumable must be spent, not merely marked"
|
||||
);
|
||||
|
||||
// A retried request replays: no second grant, and no resurrection of the
|
||||
// source it already destroyed.
|
||||
let (s, replay) = json_post(&app, "/consumables/apply", request).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(replay["applied"], serde_json::json!(false));
|
||||
assert_eq!(replay["effect"], applied["effect"]);
|
||||
let (_, twice) = json_get(&app, "/collection").await;
|
||||
let projected = twice["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["owned_card_id"] == serde_json::json!(target_id))
|
||||
.expect("target still owned")
|
||||
.clone();
|
||||
assert_eq!(projected["contract_matches"], serde_json::json!(22));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_objective_get_by_id() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
||||
//! club_active_items, 0027 consumable_applications).
|
||||
//! club_active_items, 0027 consumable_applications, 0028 contract_matches).
|
||||
//!
|
||||
//! Two things must hold on a DB that already contains real ownership:
|
||||
//! * every pre-existing owned row survives and reads back as a `player` with no
|
||||
//! stack size (the migration is a pure widening, not a rewrite);
|
||||
//! stack size and no tracked contract (the band is a pure widening, never a
|
||||
//! rewrite and never a backfill of someone else's default);
|
||||
//! * every existing kit designation lands in `club_active_items` under its
|
||||
//! generalised slot token, and the old table + trigger are gone.
|
||||
//!
|
||||
//! 0028 additionally must NOT be a table rebuild: `owned_cards` carries 0026's
|
||||
//! `clear_club_active_item_before_transfer` trigger, and a DROP/recreate would
|
||||
//! take it along silently. The trigger assertions below therefore run AFTER the
|
||||
//! whole band, not just after 0026.
|
||||
//!
|
||||
//! The first is proved against a COPY of a real populated club snapshot (1986
|
||||
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
|
||||
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
|
||||
@@ -205,6 +211,25 @@ async fn kit_assignments_migrate_into_club_active_items() {
|
||||
assert_eq!(kind, ContentKind::Player);
|
||||
assert_eq!(quantity, None);
|
||||
|
||||
// 0028: the column exists and every row that pre-dates it reads back NULL.
|
||||
// NULL is not zero — it means Core tracks no contract for the instance, so a
|
||||
// backfill here would have invented one game's pack-fresh number for all of
|
||||
// them.
|
||||
let contract = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT contract_matches FROM owned_cards WHERE id = 'spare'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("0028 must have added contract_matches");
|
||||
assert_eq!(contract, None, "a pre-existing row tracks no contract");
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET contract_matches = -1 WHERE id = 'spare'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"contract_matches CHECK must reject a negative count"
|
||||
);
|
||||
|
||||
// And the new column constraints are real, not documentation.
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
|
||||
@@ -300,10 +325,11 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
|
||||
.await
|
||||
.expect("migrations must apply to real populated data");
|
||||
|
||||
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
|
||||
let (after, players, stacked, contracted) = sqlx::query_as::<_, (i64, i64, i64, i64)>(
|
||||
"SELECT COUNT(*), \
|
||||
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END) \
|
||||
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN contract_matches IS NOT NULL THEN 1 ELSE 0 END) \
|
||||
FROM owned_cards",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
@@ -312,6 +338,7 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
|
||||
assert_eq!(after, before, "no owned row may be lost or duplicated");
|
||||
assert_eq!(players, before, "every backfilled row is a player");
|
||||
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
|
||||
assert_eq!(contracted, 0, "no pre-existing row gains a contract count");
|
||||
|
||||
assert!(table_exists(&pool, "club_active_items").await);
|
||||
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
||||
|
||||
Reference in New Issue
Block a user