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),
|
post(routes::economy::post_redeem_entitlement),
|
||||||
)
|
)
|
||||||
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/consumables/apply",
|
||||||
|
post(routes::consumables::post_apply_consumable),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/economy/grant-reward",
|
"/economy/grant-reward",
|
||||||
post(routes::economy::post_grant_reward),
|
post(routes::economy::post_grant_reward),
|
||||||
|
|||||||
+5
-1
@@ -252,6 +252,10 @@ pub struct OwnedCard {
|
|||||||
pub training_bonus: i64,
|
pub training_bonus: i64,
|
||||||
pub content_kind: ContentKind,
|
pub content_kind: ContentKind,
|
||||||
pub quantity: Option<i64>,
|
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`].
|
/// 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.
|
/// never leave a stale SELECT behind; append `WHERE …` to it.
|
||||||
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
||||||
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ pub async fn get_collection(
|
|||||||
"chemistry_style": o.chemistry_style,
|
"chemistry_style": o.chemistry_style,
|
||||||
"position_override": o.position_override,
|
"position_override": o.position_override,
|
||||||
"training_bonus": o.training_bonus,
|
"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_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
"card": def,
|
"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 auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod consumables;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
|
|||||||
+139
-43
@@ -2,17 +2,24 @@
|
|||||||
//!
|
//!
|
||||||
//! ONE Core transaction that does, in this order and nothing else:
|
//! 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;
|
//! `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;
|
//! 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
|
//! `consumable_applications`, so a duplicate is refused BEFORE anything is
|
||||||
//! mutated or consumed (same discipline as `match_completions`);
|
//! mutated or consumed (same discipline as `match_completions`). Step 1 is
|
||||||
//! 4. apply the caller's mutation to the target;
|
//! a courtesy; THIS is the guarantee, and it holds against a writer on any
|
||||||
//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
//! 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;
|
//! stack and destroy it at zero;
|
||||||
//! 6. commit.
|
//! 7. commit.
|
||||||
//!
|
//!
|
||||||
//! Anything failing at any step rolls the whole thing back: the source is never
|
//! 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
|
//! 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,
|
//! Core deliberately supplies **no per-category formula**. What a fitness card,
|
||||||
//! a contract, a chemistry style or a position modifier actually DOES to its
|
//! 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
|
//! target is the calling game adapter's reversed behaviour, passed in as
|
||||||
//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the
|
//! [`ItemMutation`] — either in-process, or described over the wire through the
|
||||||
//! honest stopping point for one is ownership + projection, i.e. not calling
|
//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. An
|
||||||
//! this function at all.
|
//! 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::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -38,6 +46,7 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||||
|
services::economy,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What the transaction does to the source instance once the effect is applied.
|
/// 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(())
|
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.
|
/// Apply one consumable to one target, exactly once. See the module docs.
|
||||||
pub async fn consume_item<M: ItemMutation>(
|
pub async fn consume_item<M: ItemMutation>(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
@@ -197,20 +216,40 @@ 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 result = async {
|
||||||
let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?;
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")?;
|
require_kind(&source, req.expected_source_kind, "source item")?;
|
||||||
|
|
||||||
// A source that is fielded in a squad cannot be consumed: `squad_players`
|
// 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.
|
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
|
||||||
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
||||||
// silently evict a lineup as a side effect of spending an item.
|
// silently evict a lineup as a side effect of spending an item.
|
||||||
let fielded =
|
let fielded = sqlx::query_scalar::<_, i64>(
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?")
|
"SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?",
|
||||||
|
)
|
||||||
.bind(req.source_owned_card_id)
|
.bind(req.source_owned_card_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
if fielded > 0 {
|
if fielded > 0 {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
@@ -219,22 +258,24 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. target.
|
// 3. target.
|
||||||
let target = match req.target {
|
let target = match req.target {
|
||||||
ConsumeTarget::OwnedCard {
|
ConsumeTarget::OwnedCard {
|
||||||
owned_card_id,
|
owned_card_id,
|
||||||
expected_kind,
|
expected_kind,
|
||||||
} => {
|
} => {
|
||||||
let card = fetch_owned(&mut tx, owned_card_id, club_id).await?;
|
let card = fetch_owned(&mut conn, owned_card_id, club_id).await?;
|
||||||
require_kind(&card, expected_kind, "target item")?;
|
require_kind(&card, expected_kind, "target item")?;
|
||||||
Some(card)
|
Some(card)
|
||||||
}
|
}
|
||||||
ConsumeTarget::Club => None,
|
ConsumeTarget::Club => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. replay guard FIRST — before the mutation and before the consumption, so
|
// 4. replay guard — the durable one. It precedes the mutation and the
|
||||||
// a duplicate cannot apply a second effect or spend a second charge. The
|
// consumption, so a duplicate that got past step 1 on another
|
||||||
// recorded `effect` is filled in below, once the mutation has produced it.
|
// 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 application_id = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let guard = sqlx::query(
|
let guard = sqlx::query(
|
||||||
@@ -252,30 +293,26 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(source.content_kind.as_str())
|
.bind(source.content_kind.as_str())
|
||||||
.bind(target.as_ref().map(|t| t.id.as_str()))
|
.bind(target.as_ref().map(|t| t.id.as_str()))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await;
|
.await;
|
||||||
match guard {
|
match guard {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
// A collision writes nothing, so this transaction has nothing to undo
|
||||||
tx.rollback().await?;
|
// and simply ends; the recorded outcome is read back afterwards.
|
||||||
return already_applied(pool, profile_id, req.action_identity).await;
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay),
|
||||||
}
|
Err(e) => return Err(e.into()),
|
||||||
Err(e) => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. the caller's effect on the target, inside this transaction.
|
// 5. the caller's effect on the target, inside this transaction.
|
||||||
let ctx = ConsumeContext {
|
let ctx = ConsumeContext {
|
||||||
profile_id: profile_id.to_string(),
|
profile_id: profile_id.to_string(),
|
||||||
club_id: club_id.to_string(),
|
club_id: club_id.to_string(),
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
};
|
};
|
||||||
let effect = mutation.apply(&mut tx, &ctx).await?;
|
let effect = mutation.apply(&mut conn, &ctx).await?;
|
||||||
|
|
||||||
// 5. consume the source exactly once. Both paths assert rows_affected == 1,
|
// 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
|
// 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
|
// lock and now sees the row gone / already decremented) fails instead of
|
||||||
// granting a second effect.
|
// granting a second effect.
|
||||||
@@ -284,7 +321,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if deleted != 1 {
|
if deleted != 1 {
|
||||||
@@ -316,7 +353,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.bind(have)
|
.bind(have)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if deleted != 1 {
|
if deleted != 1 {
|
||||||
@@ -335,7 +372,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.bind(have)
|
.bind(have)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if updated != 1 {
|
if updated != 1 {
|
||||||
@@ -358,12 +395,10 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(i64::from(source_destroyed))
|
.bind(i64::from(source_destroyed))
|
||||||
.bind(source_quantity_after)
|
.bind(source_quantity_after)
|
||||||
.bind(&application_id)
|
.bind(&application_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
Ok(Applied::Fresh(ConsumeOutcome {
|
||||||
|
|
||||||
Ok(ConsumeOutcome {
|
|
||||||
applied: true,
|
applied: true,
|
||||||
action_identity: req.action_identity.to_string(),
|
action_identity: req.action_identity.to_string(),
|
||||||
source_owned_card_id: ctx.source.id.clone(),
|
source_owned_card_id: ctx.source.id.clone(),
|
||||||
@@ -371,13 +406,41 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
source_quantity_after,
|
source_quantity_after,
|
||||||
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
||||||
effect,
|
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(profile_id)
|
||||||
|
.bind(action_identity)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(hits > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Echo the recorded outcome of an application that already happened. Mutates
|
/// Echo the recorded outcome of an application that already happened. Mutates
|
||||||
/// nothing and reports `applied = false`.
|
/// nothing and reports `applied = false`.
|
||||||
async fn already_applied(
|
async fn already_applied(
|
||||||
pool: &Pool,
|
conn: &mut SqliteConnection,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
action_identity: &str,
|
action_identity: &str,
|
||||||
) -> AppResult<ConsumeOutcome> {
|
) -> AppResult<ConsumeOutcome> {
|
||||||
@@ -388,7 +451,7 @@ async fn already_applied(
|
|||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.bind(action_identity)
|
.bind(action_identity)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::Internal(anyhow::anyhow!(
|
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]
|
#[tokio::test]
|
||||||
async fn inline_closure_effect_is_accepted() {
|
async fn inline_closure_effect_is_accepted() {
|
||||||
let (_dir, _url, pool) = fixture().await;
|
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
|
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||||
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||||
/// deadlock) — the fresh-DB multi-connection write failure.
|
/// 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 {
|
match result {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
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 fut_champs;
|
||||||
pub mod game_ext;
|
pub mod game_ext;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod instance_effect;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
|
|||||||
@@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() {
|
|||||||
assert_eq!(coins_after, coins_before + coins_received);
|
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]
|
#[tokio::test]
|
||||||
async fn test_objective_get_by_id() {
|
async fn test_objective_get_by_id() {
|
||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
//! 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:
|
//! 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
|
//! * 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
|
//! * every existing kit designation lands in `club_active_items` under its
|
||||||
//! generalised slot token, and the old table + trigger are gone.
|
//! 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
|
//! 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
|
//! 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
|
//! 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!(kind, ContentKind::Player);
|
||||||
assert_eq!(quantity, None);
|
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.
|
// And the new column constraints are real, not documentation.
|
||||||
assert!(
|
assert!(
|
||||||
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
|
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
|
.await
|
||||||
.expect("migrations must apply to real populated data");
|
.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(*), \
|
"SELECT COUNT(*), \
|
||||||
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
|
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",
|
FROM owned_cards",
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.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!(after, before, "no owned row may be lost or duplicated");
|
||||||
assert_eq!(players, before, "every backfilled row is a player");
|
assert_eq!(players, before, "every backfilled row is a player");
|
||||||
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
|
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_active_items").await);
|
||||||
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
||||||
|
|||||||
Reference in New Issue
Block a user