fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate
Four defects found by running the suites and the staging lifecycle end to end after the kit milestone. 1. club-stats kits were half-implemented. The global `kits` counter was real but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded 0, so the same screen reported two owned kits and zero home/away kits. `kits` is a total with a family split, exactly like players/playersGold and staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home family and 15 the away family, verified across all 1482 rows of the kit table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15 exactly the 64xxxxx ones, 654 rows; no exceptions either way). ClubStatInput now carries `asset_id`, and a kit buckets onto the team that wears it -- including a team the club owns no player from, the normal case for a kit won from a pack. The host reads both from the catalog through new NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a read-only stats query must never do as a side effect. 2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1/d37a9d5);56bd9ddupdated the squad_projection integration test and stopped there. `put_body` hardcoded the captured manager ref 100000427 into EVERY save, including tests with no manager fixture, so each one was refused with `unresolved_wire_ids` -- the tests were reporting a real invariant against a fixture that could not satisfy it. The manager is now an explicit `Option<i64>` per test, and FakeCore models Core's manager persistence instead of inheriting the "not implemented" default that 502'd every save. Added the coverage whose absence let this rot: a manager assignment round-trips as a Core owned id, a later save without one CLEARS it, and an unowned manager ref refuses the whole save with nothing committed. 3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit` forwarding to Core, which the kit commit deliberately replaced with host-side pagination. It only ever passed because FakeCore ignored the window -- against a real Core, `start=10` over a one-item club was always an empty page. Retargeted to the real contract (Core gets semantic filters and NO window) plus a new test that the window is applied locally after filtering, which the old fake made vacuous. 4. The staging lifecycle scripts identified production by hardcoded pids, so a correct teardown FATAL'd: production moved into containers and pids 3631953/3374264 died with a container restart days ago. A pinned pid rots into the worst of both worlds -- a kill-refusal gate that no longer names any real production process, and a liveness gate that fails a healthy teardown. New shared `scripts/openfut_production.py` resolves production pids AND published ports from the container runtime at the moment they are needed, refuses to signal anything it cannot see, and proves production is the same processes serving the same ports before and after. Both lifecycle scripts use it, which also closed a real gap: port 8085 is published by openfut-fut-backend but was missing from the up script's forbidden list, so staging could have bound a production port. Also fixes the economy differential, red because `complete_match` unlocks achievements in the same transaction that pays the match reward -- a deliberate Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted that progression did not exist; it now asserts the delta is the 400 match reward plus exactly the achievements the match unlocked, read from Core's own report.
This commit is contained in:
@@ -47,6 +47,9 @@ struct FakeCore {
|
||||
/// The stored squad + extension returned by `read_squad_ext`. A successful
|
||||
/// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write.
|
||||
squad: Mutex<Option<CoreSquadRead>>,
|
||||
/// The ownership-backed manager assignment, exactly as Core persists it: a
|
||||
/// full squad replacement writes it (or clears it with `None`).
|
||||
manager: Mutex<Option<String>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
@@ -92,6 +95,9 @@ impl FakeCore {
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
fn manager(&self) -> Option<String> {
|
||||
self.manager.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreAccess for FakeCore {
|
||||
@@ -184,6 +190,21 @@ impl CoreAccess for FakeCore {
|
||||
slots_written: req.slots.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
Ok(self.manager.lock().clone())
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
*self.manager.lock() = owned_card_id.map(str::to_string);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
@@ -472,7 +493,7 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
|
||||
let resp = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=0",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
@@ -502,8 +523,14 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
p.contains(&("club".into(), "Chelsea".into())),
|
||||
"team id 5 -> club name {p:?}"
|
||||
);
|
||||
assert!(p.contains(&("offset".into(), "10".into())));
|
||||
assert!(p.contains(&("limit".into(), "11".into())));
|
||||
// The host must NOT ask Core for a window. Kind and transfer-pile membership
|
||||
// are host-side concepts Core cannot express, so the visible set only exists
|
||||
// after local filtering and shaping; a Core-side window would paginate the
|
||||
// unfiltered set and hand the client short pages.
|
||||
assert!(
|
||||
!p.iter().any(|(k, _)| k == "offset" || k == "limit"),
|
||||
"pagination must not be delegated to Core {p:?}"
|
||||
);
|
||||
// No raw FIFA id reached Core.
|
||||
for (_, v) in &p {
|
||||
if v == "13" || v == "5" || v == "52" {
|
||||
@@ -512,6 +539,55 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's `start`/`count` window is applied by the host, over the set that
|
||||
/// survives kind + transfer-pile filtering. A window past the end is an empty
|
||||
/// page, not the first item.
|
||||
#[test]
|
||||
fn club_window_is_applied_locally_after_filtering() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![item(
|
||||
"oc1",
|
||||
"card_ch_1",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
)],
|
||||
1,
|
||||
));
|
||||
let server = build_server(
|
||||
core.clone(),
|
||||
"http://127.0.0.1:1",
|
||||
Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])),
|
||||
);
|
||||
|
||||
let in_window = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?type=player&start=0&count=11",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
let body: Value = serde_json::from_slice(&in_window.body).unwrap();
|
||||
assert_eq!(body["itemData"].as_array().unwrap().len(), 1);
|
||||
|
||||
let past_end = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?type=player&start=10&count=11",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
assert_eq!(
|
||||
past_end.status, 200,
|
||||
"a window past the end is still a page"
|
||||
);
|
||||
let body: Value = serde_json::from_slice(&past_end.body).unwrap();
|
||||
assert!(
|
||||
body["itemData"].as_array().unwrap().is_empty(),
|
||||
"offset past the visible set yields an empty page, not the first item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_route_with_empty_asset_map_drops_items_not_fakes_them() {
|
||||
// The current production reality: no card→asset mapping → empty itemData.
|
||||
@@ -888,11 +964,26 @@ fn resolver_with_wires(
|
||||
}
|
||||
|
||||
/// A FIFA squad-save body. `players` = (index, wire id, kit number).
|
||||
fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str) -> Vec<u8> {
|
||||
///
|
||||
/// `manager` is explicit because a squad save carries an ownership-backed
|
||||
/// manager assignment: a ref the resolver cannot map to an owned instance is an
|
||||
/// unresolved wire id and the whole save is refused (you cannot manage with a
|
||||
/// card you do not own). Tests that are not about the manager pass `None`.
|
||||
fn put_body(
|
||||
formation: &str,
|
||||
captain: i64,
|
||||
players: &[(i64, i64, i64)],
|
||||
custom: &str,
|
||||
manager: Option<i64>,
|
||||
) -> Vec<u8> {
|
||||
let ps: Vec<Value> = players
|
||||
.iter()
|
||||
.map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k}))
|
||||
.collect();
|
||||
let mgr: Vec<Value> = manager
|
||||
.into_iter()
|
||||
.map(|id| json!({"id": id, "dream": false}))
|
||||
.collect();
|
||||
json!({
|
||||
"id": 0,
|
||||
"formation": formation,
|
||||
@@ -903,7 +994,7 @@ fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom:
|
||||
"starRating": 90,
|
||||
"captain": captain,
|
||||
"custom": custom,
|
||||
"manager": [{"id": 100000427, "dream": false}],
|
||||
"manager": mgr,
|
||||
"players": ps,
|
||||
"kicktakers": [{"index": 0, "id": captain, "dream": false}],
|
||||
})
|
||||
@@ -1032,6 +1123,7 @@ fn numbered_squad_get_returns_current_core_squad_without_python() {
|
||||
wires["oc-a"],
|
||||
&[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
),
|
||||
);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1061,6 +1153,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1083,6 +1176,75 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref, and a later save
|
||||
/// without a manager CLEARS it (a full replacement replaces the manager too).
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
|
||||
let with_manager = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1)],
|
||||
"[]",
|
||||
Some(w["oc-b"]),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&with_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"manager persisted as the Core owned id, never the wire id"
|
||||
);
|
||||
|
||||
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager(),
|
||||
None,
|
||||
"a full replacement without a manager clears the assignment"
|
||||
);
|
||||
}
|
||||
|
||||
/// A manager ref the resolver cannot map is an unresolved wire id: the WHOLE
|
||||
/// save is refused and nothing is committed. You cannot manage with a card you
|
||||
/// do not own, and a save must never silently drop the assignment instead.
|
||||
#[test]
|
||||
fn put_refuses_a_manager_ref_that_is_not_an_owned_instance() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
|
||||
let body = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1)],
|
||||
"[]",
|
||||
Some(100_000_427),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
assert_eq!(log.outcome, "unresolved_wire_ids");
|
||||
assert_eq!(core.replace_calls(), 0, "nothing committed");
|
||||
assert_eq!(core.manager(), None, "no manager assigned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
// oc-b resolves (identity) but is NOT in the active club's owned set.
|
||||
@@ -1100,6 +1262,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1126,6 +1289,7 @@ fn put_rejects_unknown_wire_id() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
@@ -1149,6 +1313,7 @@ fn put_rejects_duplicate_owned_item() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-a"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
@@ -1168,7 +1333,7 @@ fn put_core_failure_returns_error_never_python() {
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]");
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 502);
|
||||
assert_eq!(log.outcome, "core_error");
|
||||
@@ -1190,6 +1355,7 @@ fn repeated_identical_put_is_idempotent() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (r1, _) = handle_put_squad(&body, &deps);
|
||||
let (r2, _) = handle_put_squad(&body, &deps);
|
||||
@@ -1225,6 +1391,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1330,6 +1497,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1375,6 +1543,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
handle_put_squad(&body, &deps);
|
||||
let reads_before = core.read_calls();
|
||||
@@ -1544,6 +1713,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-c"], 7)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
|
||||
Reference in New Issue
Block a user