Commit Graph

13 Commits

Author SHA1 Message Date
funman300 9026220533 feat(fifa17): manager contracts, from a Core-owned staff tier
ROOT CAUSE, one line. openfut-import-fifa17 emitted `"overall": 0` for every
non-player Core definition while `d.rating` already held EA's authoritative
`value` -- and the very next block wrote that same number correctly to the
adapter catalog. So the tier existed host-side but never reached Core:
Core overall 0 -> /collection effective_overall 0 -> CoreOwnedItem.rating 0 ->
tier_for_rating(0) = Bronze for a Gold (88) manager. That silent mis-grant is
exactly what the 409 was protecting against, so the refusal was correct.

The emitter now also writes `source_rating`, keeping `overall` at 0. Regenerating
the production pack changes exactly 18 entries and exactly one field each
(source_rating None -> value); same 1710 ids, same fingerprint 28c333f1e833338a.

WHY value IS the tier source, and why the thresholds are the player ladder:
LIVE_PROVEN, not inferred. The client re-rates staff from its own
managercards/*coachcards/physiocards by carddbid and applies discard_level's
65/75 ladder; coach_probe/discard_probe agree 4/4 (manager value 88 -> level 3,
coaches 66 -> level 2). The shipped coach tables corroborate: each family has
exactly 3 tiers x 2 rarities, and only 65/75 splits them 2/2/2.

Manager contracts stop refusing and now resolve the TARGET's tier from
Core-owned state. Still fail-closed everywhere it matters: a coach or physio is
`contract_target_not_a_manager` (only cardsubtypeid 4 is a manager), and a
manager Core carries no source_rating for is `manager_tier_unknown` rather than
a guessed tier. Core's own content_kind token is sent as target_kind, because
Core calls the squad manager `manager` while the catalog classifies it
`staff`+subtype 4.

NOT implemented, unchanged: STORED_MANAGER_BONUS and MATCH_CONTRACT_DECREMENT.
2026-08-22 20:08:26 +00:00
funman300 6c97bc4e2b feat(fifa17): real player-contract consumable apply, replacing the probe
POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.

THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.

No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.

The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".

FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.

`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.

CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.

Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
2026-08-22 18:23:22 +00:00
funman300 49b18dd4ac fifa17: price quick-sell from the client's own discard table
Quick-sell paid an invented five-tier rating ladder (its own comment said
"PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a
94-rated TOTW special and a 94-rated gold common both sold for 1500, and every
non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder
existed in three places (adapter wire, host payout, an integration test's private
copy), which is a drift waiting to happen.

Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins
table and its formula, round_half_up(rating * price / 100), keyed
(cardtype, level, rare). All of it is already reversed in
plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live
club items, 22 of 22 exact. DISCARD_COINS is generated from
fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and
asserts row-for-row agreement, so the transcription cannot drift.

Collapse the three ladders into one method. ItemIdentityResolver::discard_value
both stamps the wire discardValue and prices the sale, because a non-zero
discardValue suppresses the client's local computation -- whatever is sent is
what the player is promised. The host's quick_sell_value is deleted and the
integration test's copy now calls the single implementation. A test with a
resolver double returning an impossible price proves the credit follows the wire;
reverting the payout to a ladder fails it.

Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real
1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for
specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an
operator's decision.

Staff decline to the ladder rather than pay 0: the client re-rates cardtypes
2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not
guessed -- see the falsifier in the doc.

Verified on staging with the real club, both modes: flag off 1500 wire / 1500
paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100).
Consumables price from their catalog rating and agree with the client's own
computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy
clean.
2026-08-21 22:40:04 +00:00
funman300 9823bdac78 feat(fifa17): project badges and stadiums, the other two cardtype-7 club items
Kit, badge and stadium are ONE record with ONE client-side resolver
(`FUN_180119bd0`, dispatched on `item+0x4c == 7`); they differ only in the field
their caption reads. Kits already ship and render, so the record is live-proven
— badges and stadiums were being withheld as if unreversed when the authority
(`plan-2026-08-06-card-subsystem.md`) marks both CONFIRMED, and its own rollout
order is "kits first, then badges, then stadia".

So the shaper generalises to the family, and carries exactly what each caption
resolves: `teamid` for kit and badge (`TeamName_Abbr15_<teamid>`), withheld for
stadium, whose resolver reads `StadiumName_<assetId>` and never looks at teamid.
Sending a field the resolver does not read is how this project earned a client
freeze.

Ball (30) and league logo (31) stay withheld. They are cardtype 9 with NO
database name resolver, so their name can only come from `localizedName`: the
offset is confirmed, but "the parser reads it" is not "sending it is safe".

Verified against the real club on staging: badge 6000005 emits cardsubtypeid 11
/ cardassetid 39 / teamid 21, stadium 6200000 emits cardsubtypeid 10 /
cardassetid 36 and no teamid, ball and logo emit nothing.
2026-08-21 20:45:29 +00:00
funman300 6c7d0856b6 feat(fifa17): project every owned content kind, from one recovered vocabulary
Extends the FIFA17 adapter past players so the wire can carry the rest of a
real club's inventory.

itemState: the recovered 12-row table at 0x180229cc0 becomes the single source
(`fut::item_state`), replacing scattered literals. Every shaper draws from it
and the tests assert no shaper can emit a state the client does not know.
CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start.

ContentKind covers all nine tokens. Managers stay inside the staff family for
counting, because the client's own club-stats model puts a manager INSIDE the
staff total with staffManager as a sub-bucket — a parallel Manager kind would
silently under-count.

Consumables get their own route (`club/consumables/<category>`) and a
stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a
`?type=` family. This path previously fell through to Python, so owned
inventory was being served by the oracle.

The shaper refuses to emit a card it cannot render: no known art id, or a
missing `amount`/`contract` for the families that read them, or the subtype-219
rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped
card is counted and logged, never faked.
2026-08-21 19:49:52 +00:00
funman300 12ad04c9d4 fix(fifa17): carry the manager's item in the squad, not just its id
The operator picked a manager in the FUT hub, then found no manager on the
pre-match squad. The save was NOT the problem: the host logged three
`route=squad-replace status=200 outcome=ok detail=[]` with no unresolved ref,
Core wrote the `squad_managers` row, and every read projected the assignment
back. The client simply had nothing to draw.

`squad.manager[]` was emitted as a bare `[{id, dream}]`. That looked
retail-faithful, and the previous commit defended it on the grounds that no
capture had ever shown otherwise. Re-reading the captures with a populated
manager in hand shows why that was the wrong conclusion: every retail capture
carrying the bare form has `id: 0` — an EMPTY manager. None of them ever
demonstrated that a POPULATED ref renders without its item, because none of them
had one. `plan-2026-08-05-families.md` says as much outright: "FUN_18013d1f0 was
never read for a staff member".

The squad object is self-contained everywhere else: `players[].itemData` carries
the whole card rather than an id resolved out of band. The manager is the same
kind of slot in the same object, and the one implementation that ever drove a
working manager — the Python oracle's squad — emits `id` BESIDE `itemData`.
The element shapes differ and both are now pinned by tests: a player slot is
`{index, itemData, kitNumber}`, the manager is `{id, itemData, dream}`.

So the manager is projected through `resolve_staff` and its item embedded with
`shape_staff_item`, the same 11-key record `/club` serves. An assignment with no
resolvable staff identity still yields `[]` rather than a fabricated ref.

`STAFF_CONTRACT` moves next to `shape_staff_item` in `fut::item` (re-exported
from `club_response`) so `/club` and the squad cannot disagree about the
contract the client checks before kickoff.

Verified on the restored club: userMassInfo, /squad/0 and /squad/active all
carry the manager with resourceId 1000509, contract 7 and the nation/league/team
the client cannot supply itself. Adapter 217 lib + 25 integration, host 114 lib +
36 host_test and every economy suite green.
2026-08-21 17:30:14 +00:00
funman300 9c2edc4eee feat(fifa17): serve staff, so the club has a manager and matches can start
FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.

The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.

Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:

* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
  client's merge never writes, so the server is their only source — they are the
  flag, the league badge and both halves of manager chemistry. Coaches get
  neither, because the four coach tables have no nation/league/team column and
  emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
  mask (players are the only masked family), so `version` must stay 0 or the
  lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
  and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
  omitted because the merge overwrites them from the client's own tables. A
  staff card is therefore never routed through `shape_item`.

Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.

`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.

TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:

1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
   assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
   indistinguishable from "no manager". Now reads `id`, and a present-but-
   unreadable manager is an error rather than a silent absence. The projection
   also now warns when an assignment cannot be resolved to an owned instance,
   which is the documented "Core drops an owned card with no CardDefinition from
   /collection without erroring" trap.

2. `Route::WatchList` was produced by NO classifier arm, so its handler was
   unreachable and every `watchList` request fell through to Passthrough — the
   same defect class as `season/list`. Against a stack whose Python upstream is
   deliberately dead this 502'd. This was failing
   `sbc_survives_complete_core_and_host_restart` at HEAD before this change.

The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.

Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
2026-08-21 17:13:44 +00:00
funman300 db743ffd1f feat(fifa17): project owned kits with active home/away designation
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.

adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
  consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
  optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
  (id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
  untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
  the 101/102 integers are the client's post-deserialisation runtime enum
  (item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.

host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
  defaulting to no active kits so a Core without the endpoint degrades
  instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
  empty page and outcome=unsupported_type, and now always fetches
  unpaginated from Core: kind and transfer-pile membership are host-side
  concepts Core cannot express, so filtering and pagination must both
  happen after shaping or pages come back short.

importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
  kit counts/balances, and card_asset_id/team_id carried into the emitted
  catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
  (missing_kit_render_metadata) rather than defaulted; conflicting render
  metadata across instances defers as render_metadata_conflict.

staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
2026-08-21 03:17:57 +00:00
funman300 abe9e663c1 feat(fifa17): import consumable + staff content as first-class Core content
Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are
unrecoverable from PC data). Verdict A (no Core change): consumables/staff become
ordinary Core CardDefinitions (neutral player fields + honest family/role names)
and owned instances via the SAME generic import path; a catalog kind lets the
adapter exclude them from the player-only /club projection.

- adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label
  (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate.
- adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype
  (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups.
- adapter item/club_response: shape_club_response excludes non-player kinds
  (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player.
- host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so
  /club excludes consumables/staff in production.
- import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions
  (resourceId-grouped, subtype-consistency gated); emit_content writes non-player
  defs + catalog kind + manifest; apply mints owned instances via owned_item_id.

Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player
defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers.
2026-08-14 05:26:02 +00:00
funman300 626c972232 fix(fifa17): carry observed rareflag so special cards render as specials
shape_item hardcoded rareflag=1, so all 1949 cards shaped as basic rare gold
regardless of type; informs/specials lost their card art. The dev fixture is
base-only, so this was invisible until the real profile (10 distinct rareflag
values) exposed it on .105.

rareflag is definition-level FIFA identity metadata OBSERVED from the profile
(the raw wire integer, never a guessed marketing label), so it lives in the
FIFA catalog like asset_id/version, not in generic Core:
- ObservedDefinition.rareflag + emitted into the production catalog entry.
- Fifa17CardCatalog RawCard/Fifa17CardIdentity gain rareflag (default 1 when a
  base-only catalog omits it, preserving prior wire behaviour).
- Fifa17Identity.rareflag; host resolver populates it from the catalog.
- shape_item emits id.rareflag instead of a hardcoded 1.

Verified on the real staged /club: wire rareflag distribution == source exactly
(0 per-item mismatches across 1949; e.g. rareflag 3 x591, 24 x302, 21 x256).
adapter 111 + host 24 + importer 25 tests green; clippy -D clean. rareflag lives
in the host catalog, not Core, so no re-import was needed.
2026-08-12 21:13:51 +00:00
funman300 e187cd49a2 fix(fifa17): preserve versioned resourceId on the wire (no special->base collapse)
shape_item emitted resourceId/definitionId = asset_id (base), collapsing every
versioned (special) card onto its base definition on the /club and squad wire.
The dev 32-card fixture is base-only (version 0), so Slice 7 never exposed it;
the real profile (1531 versioned cards) did.

Fifa17Identity now carries resource_id (= (version<<24)|asset_id, == asset_id
for a base card). shape_item emits resourceId/definitionId from resource_id and
assetId/cardassetid from asset_id — versioned and base stay distinct. The host
resolver populates resource_id from the catalog's reconstructed resource_id
(the catalog already parsed version; it was dropped before shaping).

Regression test: versioned 117617092 (v7 of asset 176580) shapes resourceId/
definitionId=117617092, assetId/cardassetid=176580.

Verified on the real staged /club: 1949 items, wire-id set exact, 0
wire->resourceId mismatches, 0 duplicate-multiplicity mismatches vs the source
manifest. adapter 111 + host 24 tests green; clippy -D warnings clean.
2026-08-12 20:46:48 +00:00
funman300 b50e0359f7 feat(adapter): extract shared FIFA17 FUT item-shaping primitive
Move the per-item card shaper (CoreOwnedItem, Fifa17Identity,
ItemIdentityResolver, ShapeStats, shape_item) out of club_response into
fut::item so /club and the upcoming squad projection emit byte-identical
items from one source of truth. club_response keeps only the /club
{itemData:[...]} envelope and re-exports the moved types for API
stability. shape_item is now pub; no behavior change (all /club and
oracle-parity tests unchanged and green).

Adds item-shaper tests: full-field identity mapping and the duplicate
owned-copy invariant (two instances of one definition keep distinct wire
ids, share one asset id).
2026-08-12 02:14:41 +00:00
funman300 c0a3f68ded feat(utas): FIFA17 UTAS migration host + /club adapter mappings
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).

openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).

openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
2026-08-11 21:40:15 +00:00