fix(fifa17): a dangling manager ref must not refuse the squad save
Every real squad save was failing with 400 unresolved_wire_ids. Reproduced on staging with the repo's own seeder, which sends the captured retail body: route=squad-replace status=400 outcome=unresolved_wire_ids detail=[[100000427]] FIFA 17 always sends a manager ref, and on a real profile it does not resolve to an owned instance. scripts/sold-staging-seed-squad.py already recorded why: production's own squad points at instance 100000427, which is absent from production's /club/staff (1975 items spanning 100000001..100004826), and the client accepts that squad back unchanged -- so the client never validates the manager against the club, and the pre-0023 server accepted it. Making the manager ownership-backed (d37a9d5/25f4ad1) turned that ref into a hard refusal, which took out the primary FUT write path: no squad save means no squad, which means the client will not enter the FUT hub at all. A manager ref is not a squad slot. An unresolvable PLAYER slot must still refuse the save -- committing it would silently drop an owned card from the club. An unresolvable MANAGER ref just means there is no ownership-backed manager, which is exactly the state before migration 0023: the save commits, the assignment is cleared as a full replacement should, and the id is reported on ProposedSquad::unresolved_manager_wire_id so the host can log what it could not map instead of letting it vanish. A ref that DOES resolve is still assigned and still authorized against the club. Verified end to end on staging: the seeder now answers {"id": 0} with 11 occupied slots, the host logs `manager_ref_unresolved=100000427 (saved with no manager assignment)`, and the projected squad carries `manager: []`.
This commit is contained in:
@@ -144,13 +144,28 @@ pub struct ProposedSquad {
|
|||||||
/// The owned instance assigned as the squad's **manager**, reverse-resolved
|
/// The owned instance assigned as the squad's **manager**, reverse-resolved
|
||||||
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
|
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
|
||||||
/// is ownership-backed, never a dangling wire id). `None` when the save
|
/// is ownership-backed, never a dangling wire id). `None` when the save
|
||||||
/// carries no manager. A manager wire id the resolver cannot map is reported
|
/// carries no manager, or when its manager ref does not resolve — see
|
||||||
/// in `unresolved_wire_ids` — a save is refused rather than assigning a
|
/// [`Self::unresolved_manager_wire_id`].
|
||||||
/// manager the club does not own.
|
|
||||||
pub manager_owned_card_id: Option<String>,
|
pub manager_owned_card_id: Option<String>,
|
||||||
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
|
/// A non-zero manager ref the resolver could not map, if any.
|
||||||
/// replacement if this is non-empty — a save must never silently drop an
|
///
|
||||||
/// owned player it failed to identify.
|
/// This does NOT refuse the save. FIFA 17 sends a manager ref that is not an
|
||||||
|
/// owned club item: production's own squad points at instance 100000427,
|
||||||
|
/// which is absent from production's `/club/staff` listing (1975 items,
|
||||||
|
/// 100000001..100004826), and the client accepts that squad back unchanged —
|
||||||
|
/// so the client does not validate the manager against the club, and refusing
|
||||||
|
/// the save would break EVERY real squad save for a field that was not even
|
||||||
|
/// ownership-backed before migration 0023.
|
||||||
|
///
|
||||||
|
/// An unresolvable ref therefore means "no ownership-backed manager": the
|
||||||
|
/// assignment is cleared, exactly as a full replacement should, and the id is
|
||||||
|
/// reported so the host can log what it could not map. An occupied PLAYER
|
||||||
|
/// slot is different and still refuses the save — dropping one would silently
|
||||||
|
/// lose an owned card from the club.
|
||||||
|
pub unresolved_manager_wire_id: Option<i64>,
|
||||||
|
/// Occupied PLAYER wire item ids the resolver could not map. A caller MUST
|
||||||
|
/// refuse the replacement if this is non-empty — a save must never silently
|
||||||
|
/// drop an owned player it failed to identify.
|
||||||
pub unresolved_wire_ids: Vec<i64>,
|
pub unresolved_wire_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +230,15 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Manager: the first non-zero manager ref, reverse-resolved to an owned
|
// Manager: the first non-zero manager ref, reverse-resolved to an owned
|
||||||
// instance. An unresolvable manager is an unresolved wire id (refused), not a
|
// instance. Unresolvable is NOT fatal (see `unresolved_manager_wire_id`) --
|
||||||
// silently dropped assignment — you cannot manage with a card you don't own.
|
// the real client always sends a dangling ref, so refusing would break every
|
||||||
|
// squad save.
|
||||||
let mut manager_owned_card_id = None;
|
let mut manager_owned_card_id = None;
|
||||||
|
let mut unresolved_manager_wire_id = None;
|
||||||
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
|
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
|
||||||
match resolver.owned_id_for_wire(wire) {
|
match resolver.owned_id_for_wire(wire) {
|
||||||
Some(owned) => manager_owned_card_id = Some(owned),
|
Some(owned) => manager_owned_card_id = Some(owned),
|
||||||
None => unresolved.push(wire),
|
None => unresolved_manager_wire_id = Some(wire),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProposedSquad {
|
ProposedSquad {
|
||||||
@@ -230,6 +247,7 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
|
|||||||
formation: put.formation.clone(),
|
formation: put.formation.clone(),
|
||||||
slots,
|
slots,
|
||||||
manager_owned_card_id,
|
manager_owned_card_id,
|
||||||
|
unresolved_manager_wire_id,
|
||||||
unresolved_wire_ids: unresolved,
|
unresolved_wire_ids: unresolved,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,15 +316,24 @@ mod tests {
|
|||||||
assert_eq!(ext.kicktakers[0].index, 0);
|
assert_eq!(ext.kicktakers[0].index, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
|
||||||
|
/// sends one, and on a real profile it is dangling (production points at
|
||||||
|
/// 100000427, absent from its own /club/staff). The save commits with no
|
||||||
|
/// ownership-backed manager and reports the id it could not map.
|
||||||
#[test]
|
#[test]
|
||||||
fn build_refuses_an_unowned_manager() {
|
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
|
||||||
// Manager wire id present but NOT resolvable → refused, never assigned a
|
|
||||||
// manager the club does not own.
|
|
||||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||||
let mut ids = full_resolver().0;
|
let mut ids = full_resolver().0;
|
||||||
ids.remove(&100000427);
|
ids.remove(&100000427);
|
||||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
|
||||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000427]));
|
assert_eq!(build.canonical.manager_owned_card_id, None);
|
||||||
|
assert_eq!(
|
||||||
|
build.canonical.unresolved_manager_wire_id,
|
||||||
|
Some(100000427),
|
||||||
|
"the ref we could not map is reported, not swallowed"
|
||||||
|
);
|
||||||
|
// The starting XI is untouched -- only the manager assignment is dropped.
|
||||||
|
assert_eq!(build.canonical.slots.len(), 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-1
Submodule openfut-launcher updated: 44ebc4b23c...7edf682291
@@ -2131,6 +2131,17 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// FIFA always sends a manager ref, and on a real profile it does not resolve
|
||||||
|
// to an owned instance (production's own save points at 100000427, absent
|
||||||
|
// from its /club/staff). That is not an error: the save commits with NO
|
||||||
|
// ownership-backed manager. Logged so a ref we cannot map stays visible
|
||||||
|
// instead of vanishing.
|
||||||
|
if let Some(wire) = build.canonical.unresolved_manager_wire_id {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=squad-replace manager_ref_unresolved={wire} \
|
||||||
|
(saved with no manager assignment)"
|
||||||
|
);
|
||||||
|
}
|
||||||
// Commit canonical + extension atomically. No Python fallback on failure.
|
// Commit canonical + extension atomically. No Python fallback on failure.
|
||||||
let req = CoreReplaceRequest {
|
let req = CoreReplaceRequest {
|
||||||
name: build.canonical.name.clone(),
|
name: build.canonical.name.clone(),
|
||||||
|
|||||||
@@ -1216,11 +1216,13 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A manager ref the resolver cannot map is an unresolved wire id: the WHOLE
|
/// The REAL client always sends a manager ref, and on a real profile it does not
|
||||||
/// save is refused and nothing is committed. You cannot manage with a card you
|
/// resolve: production's own save points at 100000427, which is absent from
|
||||||
/// do not own, and a save must never silently drop the assignment instead.
|
/// production's `/club/staff`, and the client accepts that squad back unchanged.
|
||||||
|
/// Refusing the save would therefore break EVERY squad save, so an unresolvable
|
||||||
|
/// manager ref commits the squad with NO manager assignment.
|
||||||
#[test]
|
#[test]
|
||||||
fn put_refuses_a_manager_ref_that_is_not_an_owned_instance() {
|
fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
|
||||||
let items = vec![gk(), st()];
|
let items = vec![gk(), st()];
|
||||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||||
let core = FakeCore::new(items.clone(), 2);
|
let core = FakeCore::new(items.clone(), 2);
|
||||||
@@ -1239,10 +1241,28 @@ fn put_refuses_a_manager_ref_that_is_not_an_owned_instance() {
|
|||||||
Some(100_000_427),
|
Some(100_000_427),
|
||||||
);
|
);
|
||||||
let (resp, log) = handle_put_squad(&body, &deps);
|
let (resp, log) = handle_put_squad(&body, &deps);
|
||||||
|
assert_eq!(resp.status, 200, "{log:?}");
|
||||||
|
assert_eq!(resp.body, br#"{"id":0}"#);
|
||||||
|
assert_eq!(core.replace_calls(), 1, "the squad itself is committed");
|
||||||
|
assert_eq!(
|
||||||
|
core.manager(),
|
||||||
|
None,
|
||||||
|
"no ownership-backed manager is invented from a ref we cannot map"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An occupied PLAYER slot that does not resolve is the opposite case: it
|
||||||
|
// would silently lose an owned card, so it still refuses the whole save.
|
||||||
|
let bad_player = put_body(
|
||||||
|
"f442",
|
||||||
|
w["oc-a"],
|
||||||
|
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
|
||||||
|
"[]",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let (resp, log) = handle_put_squad(&bad_player, &deps);
|
||||||
assert_eq!(resp.status, 400);
|
assert_eq!(resp.status, 400);
|
||||||
assert_eq!(log.outcome, "unresolved_wire_ids");
|
assert_eq!(log.outcome, "unresolved_wire_ids");
|
||||||
assert_eq!(core.replace_calls(), 0, "nothing committed");
|
assert_eq!(core.replace_calls(), 1, "nothing further committed");
|
||||||
assert_eq!(core.manager(), None, "no manager assigned");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user