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:
@@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() {
|
||||
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]
|
||||
async fn test_objective_get_by_id() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
//! 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:
|
||||
//! * 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
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -205,6 +211,25 @@ async fn kit_assignments_migrate_into_club_active_items() {
|
||||
assert_eq!(kind, ContentKind::Player);
|
||||
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.
|
||||
assert!(
|
||||
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
|
||||
.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(*), \
|
||||
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",
|
||||
)
|
||||
.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!(players, before, "every backfilled row is a player");
|
||||
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_kit_assignments").await);
|
||||
|
||||
Reference in New Issue
Block a user