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); 56bd9dd updated 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:
funman300
2026-08-21 04:10:02 +00:00
parent db743ffd1f
commit 3442eac6f0
7 changed files with 568 additions and 107 deletions
+109 -15
View File
@@ -24,14 +24,18 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
/// One owned item, already classified from the catalog + entity tables by the
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/
/// `league_id`/`team_id` from the reverse entity resolver for players and from
/// the kit table for kits (None = unresolved, bucket skipped).
#[derive(Debug, Clone)]
pub struct ClubStatInput {
pub kind: ContentKind,
pub subtype: i64,
pub rating: i64,
pub rare: bool,
/// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family
/// discriminator, which is what splits the home/away kit counters.
pub asset_id: i64,
pub nation_id: Option<i64>,
pub league_id: Option<i64>,
pub team_id: Option<i64>,
@@ -58,8 +62,17 @@ const S_RARE: i64 = 0x05;
const S_STAFF: i64 = 0x0A;
const S_CONSUMABLES: i64 = 0x3C;
const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D;
/// `fcc_kitcards.assetid` splits the kit table into the home and away families.
/// Verified across all 1482 rows of the FIFA 17 kit table: assetid 14 covers
/// exactly the `63xxxxx` carddbids (828 rows) and assetid 15 exactly the
/// `64xxxxx` ones (654 rows), with no exceptions in either direction.
const KIT_ASSET_HOME: i64 = 14;
const KIT_ASSET_AWAY: i64 = 15;
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
fn staff_stat(subtype: i64) -> Option<i64> {
match subtype {
@@ -216,20 +229,22 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// Club items: kits are Core-owned and counted; unimplemented families stay
// honest zeros.
// Club items: kits are Core-owned and counted (total plus the home/away
// family split); unimplemented families stay honest zeros.
for sid in [
0x14, 0x1E, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
g.entry(sid).or_insert(0);
}
g.insert(
S_KITS,
items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
.count() as i64,
);
let kits: Vec<&ClubStatInput> = items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
.collect();
let kits_with_asset =
|asset: i64| kits.iter().filter(|item| item.asset_id == asset).count() as i64;
g.insert(S_KITS, kits.len() as i64);
g.insert(S_KITS_HOME, kits_with_asset(KIT_ASSET_HOME));
g.insert(S_KITS_AWAY, kits_with_asset(KIT_ASSET_AWAY));
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
@@ -247,10 +262,30 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
by_ctx.entry(id).or_default().push(p);
}
}
// A kit belongs to the team that wears it and has no nation/league of its
// own, so it only buckets on the team screen -- and it buckets there even if
// the club owns no player from that team, which is the normal case for a kit
// won from a pack.
let mut kits_by_team: BTreeMap<i64, i64> = BTreeMap::new();
if ctx == ContextField::Team {
for kit in &kits {
if let Some(id) = kit.team_id {
*kits_by_team.entry(id).or_insert(0) += 1;
by_ctx.entry(id).or_default();
}
}
}
for (cid, sel) in &by_ctx {
if ctx == ContextField::Team {
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
stat.push(row(3, *cid, S_KITS, 0));
stat.push(row(
3,
*cid,
S_KITS,
kits_by_team.get(cid).copied().unwrap_or(0),
));
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
} else {
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
@@ -279,6 +314,7 @@ mod tests {
subtype: 0,
rating,
rare,
asset_id: 158023,
nation_id: nation,
league_id: None,
team_id: None,
@@ -290,6 +326,7 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
@@ -301,22 +338,29 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
}
}
fn kit() -> ClubStatInput {
/// A kit of the given family (`KIT_ASSET_HOME` / `KIT_ASSET_AWAY`) worn by
/// `team`.
fn kit_of(asset_id: i64, team: i64) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Kit,
subtype: 9,
rating: 0,
rare: false,
asset_id,
nation_id: None,
league_id: None,
team_id: Some(21),
team_id: Some(team),
}
}
fn kit() -> ClubStatInput {
kit_of(KIT_ASSET_HOME, 21)
}
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
body["stat"]
@@ -383,6 +427,56 @@ mod tests {
assert_eq!(g["kits"], 2);
}
/// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the
/// same total/subset shape as players/playersGold and staff/staffManager.
#[test]
fn kit_counts_split_by_home_and_away_family() {
let items = vec![
kit_of(KIT_ASSET_HOME, 21),
kit_of(KIT_ASSET_HOME, 38),
kit_of(KIT_ASSET_AWAY, 21),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["kits"], 3);
assert_eq!(g["kitsHome"], 2);
assert_eq!(g["kitsAway"], 1);
}
/// A kit buckets onto the team that wears it -- including a team the club
/// owns no player from, which is the normal case for a kit won from a pack.
#[test]
fn kits_bucket_onto_their_own_team_on_the_team_screen() {
let mut with_team = player(90, false, None);
with_team.team_id = Some(21);
let items = vec![
with_team,
kit_of(KIT_ASSET_HOME, 21),
kit_of(KIT_ASSET_AWAY, 21),
kit_of(KIT_ASSET_HOME, 38),
];
let body = club_stats_body(&items, ContextField::Team);
let kits_for = |team: i64| {
body["stat"]
.as_array()
.unwrap()
.iter()
.find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits")
.map(|r| r["typeValue"].as_i64().unwrap())
};
assert_eq!(kits_for(21), Some(2));
// Team 38 has no players, so only the kit creates its bucket.
assert_eq!(kits_for(38), Some(1));
// A nation/league screen has no team context, so kits stay out of it.
let nation = club_stats_body(&items, ContextField::Nation);
assert!(nation["stat"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["contextId"] == 3 && r["type"] == "kits")
.all(|r| r["typeValue"] == 0));
}
#[test]
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
let items = vec![