Files
OpenFUT-Core/src/routes/consumables.rs
T
funman300 e8be289660
CI / Build, lint & test (push) Successful in 3m16s
feat(consume): durable per-instance contract state + HTTP apply route
`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.
2026-08-22 18:23:05 +00:00

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))
}