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,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))
|
||||
}
|
||||
Reference in New Issue
Block a user