//! `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, game: GameId, Json(req): Json, ) -> AppResult> { // 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)) }