e8be289660
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.
83 lines
3.2 KiB
Rust
83 lines
3.2 KiB
Rust
//! `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))
|
|
}
|