Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3084a46dcc | |||
| 9b2c6b82f2 | |||
| 615c5fd7a5 | |||
| 36abd4b6fb | |||
| 6acae54f80 | |||
| aecbff0de8 |
@@ -0,0 +1,546 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "fifa17_101490",
|
||||||
|
"name": "Conor Casey",
|
||||||
|
"overall": 64,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "United States",
|
||||||
|
"league": "MLS",
|
||||||
|
"club": "Columbus Crew SC",
|
||||||
|
"pace": 43,
|
||||||
|
"shooting": 65,
|
||||||
|
"passing": 52,
|
||||||
|
"dribbling": 60,
|
||||||
|
"defending": 33,
|
||||||
|
"physical": 72,
|
||||||
|
"rarity": "bronze",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_101880",
|
||||||
|
"name": "Rob Green",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "EFL Championship",
|
||||||
|
"club": "Leeds United",
|
||||||
|
"pace": 78,
|
||||||
|
"shooting": 70,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 77,
|
||||||
|
"defending": 47,
|
||||||
|
"physical": 71,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_102356",
|
||||||
|
"name": "Markus Feulner",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Augsburg",
|
||||||
|
"pace": 58,
|
||||||
|
"shooting": 70,
|
||||||
|
"passing": 75,
|
||||||
|
"dribbling": 71,
|
||||||
|
"defending": 66,
|
||||||
|
"physical": 71,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_102593",
|
||||||
|
"name": "Craig Woodman",
|
||||||
|
"overall": 64,
|
||||||
|
"position": "LB",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "EFL League Two",
|
||||||
|
"club": "Exeter City",
|
||||||
|
"pace": 66,
|
||||||
|
"shooting": 45,
|
||||||
|
"passing": 58,
|
||||||
|
"dribbling": 60,
|
||||||
|
"defending": 62,
|
||||||
|
"physical": 65,
|
||||||
|
"rarity": "bronze",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_105046",
|
||||||
|
"name": "Anders Østli",
|
||||||
|
"overall": 64,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Norway",
|
||||||
|
"league": "Tippeligaen",
|
||||||
|
"club": "Sarpsborg 08 FF",
|
||||||
|
"pace": 54,
|
||||||
|
"shooting": 46,
|
||||||
|
"passing": 54,
|
||||||
|
"dribbling": 52,
|
||||||
|
"defending": 62,
|
||||||
|
"physical": 75,
|
||||||
|
"rarity": "bronze",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_107298",
|
||||||
|
"name": "Yohann Pelé",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Ligue 1",
|
||||||
|
"club": "O. de Marseille",
|
||||||
|
"pace": 75,
|
||||||
|
"shooting": 74,
|
||||||
|
"passing": 72,
|
||||||
|
"dribbling": 70,
|
||||||
|
"defending": 49,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_107713",
|
||||||
|
"name": "Tom Starke",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Bayern",
|
||||||
|
"pace": 76,
|
||||||
|
"shooting": 73,
|
||||||
|
"passing": 59,
|
||||||
|
"dribbling": 72,
|
||||||
|
"defending": 39,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_110020",
|
||||||
|
"name": "Sergio Pelegrín",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "LaLiga 1 I 2 I 3",
|
||||||
|
"club": "Elche CF",
|
||||||
|
"pace": 45,
|
||||||
|
"shooting": 32,
|
||||||
|
"passing": 52,
|
||||||
|
"dribbling": 49,
|
||||||
|
"defending": 75,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_110026",
|
||||||
|
"name": "Cani",
|
||||||
|
"overall": 74,
|
||||||
|
"position": "LM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "LaLiga 1 I 2 I 3",
|
||||||
|
"club": "Real Zaragoza",
|
||||||
|
"pace": 67,
|
||||||
|
"shooting": 72,
|
||||||
|
"passing": 73,
|
||||||
|
"dribbling": 78,
|
||||||
|
"defending": 45,
|
||||||
|
"physical": 61,
|
||||||
|
"rarity": "silver",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_11811",
|
||||||
|
"name": "Paul Green",
|
||||||
|
"overall": 64,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "Republic of Ireland",
|
||||||
|
"league": "EFL League One",
|
||||||
|
"club": "Oldham Athletic",
|
||||||
|
"pace": 65,
|
||||||
|
"shooting": 58,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 63,
|
||||||
|
"defending": 62,
|
||||||
|
"physical": 68,
|
||||||
|
"rarity": "bronze",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_139720",
|
||||||
|
"name": "Vincent Kompany",
|
||||||
|
"overall": 86,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Belgium",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester City",
|
||||||
|
"pace": 69,
|
||||||
|
"shooting": 54,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 65,
|
||||||
|
"defending": 86,
|
||||||
|
"physical": 81,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_146562",
|
||||||
|
"name": "Santi Cazorla",
|
||||||
|
"overall": 86,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Arsenal",
|
||||||
|
"pace": 71,
|
||||||
|
"shooting": 78,
|
||||||
|
"passing": 85,
|
||||||
|
"dribbling": 86,
|
||||||
|
"defending": 57,
|
||||||
|
"physical": 64,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_153079",
|
||||||
|
"name": "Sergio Agüero",
|
||||||
|
"overall": 89,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Argentina",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester City",
|
||||||
|
"pace": 89,
|
||||||
|
"shooting": 88,
|
||||||
|
"passing": 75,
|
||||||
|
"dribbling": 89,
|
||||||
|
"defending": 23,
|
||||||
|
"physical": 70,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_158023",
|
||||||
|
"name": "Lionel Messi",
|
||||||
|
"overall": 93,
|
||||||
|
"position": "RW",
|
||||||
|
"nation": "Argentina",
|
||||||
|
"league": "LaLiga Santander",
|
||||||
|
"club": "FC Barcelona",
|
||||||
|
"pace": 89,
|
||||||
|
"shooting": 90,
|
||||||
|
"passing": 86,
|
||||||
|
"dribbling": 96,
|
||||||
|
"defending": 26,
|
||||||
|
"physical": 61,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_162895",
|
||||||
|
"name": "Cesc Fàbregas",
|
||||||
|
"overall": 86,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Chelsea",
|
||||||
|
"pace": 63,
|
||||||
|
"shooting": 77,
|
||||||
|
"passing": 89,
|
||||||
|
"dribbling": 81,
|
||||||
|
"defending": 61,
|
||||||
|
"physical": 64,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_163705",
|
||||||
|
"name": "Steve Mandanda",
|
||||||
|
"overall": 85,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Crystal Palace",
|
||||||
|
"pace": 86,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 79,
|
||||||
|
"dribbling": 85,
|
||||||
|
"defending": 49,
|
||||||
|
"physical": 81,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_165229",
|
||||||
|
"name": "Laurent Koscielny",
|
||||||
|
"overall": 85,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Arsenal",
|
||||||
|
"pace": 78,
|
||||||
|
"shooting": 40,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 65,
|
||||||
|
"defending": 85,
|
||||||
|
"physical": 78,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_167948",
|
||||||
|
"name": "Hugo Lloris",
|
||||||
|
"overall": 88,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Spurs",
|
||||||
|
"pace": 87,
|
||||||
|
"shooting": 87,
|
||||||
|
"passing": 68,
|
||||||
|
"dribbling": 90,
|
||||||
|
"defending": 64,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_168542",
|
||||||
|
"name": "David Silva",
|
||||||
|
"overall": 87,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester City",
|
||||||
|
"pace": 68,
|
||||||
|
"shooting": 72,
|
||||||
|
"passing": 87,
|
||||||
|
"dribbling": 87,
|
||||||
|
"defending": 32,
|
||||||
|
"physical": 58,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_176580",
|
||||||
|
"name": "Luis Suárez",
|
||||||
|
"overall": 92,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Uruguay",
|
||||||
|
"league": "LaLiga Santander",
|
||||||
|
"club": "FC Barcelona",
|
||||||
|
"pace": 82,
|
||||||
|
"shooting": 90,
|
||||||
|
"passing": 79,
|
||||||
|
"dribbling": 87,
|
||||||
|
"defending": 42,
|
||||||
|
"physical": 79,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_176635",
|
||||||
|
"name": "Mesut Özil",
|
||||||
|
"overall": 89,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Arsenal",
|
||||||
|
"pace": 72,
|
||||||
|
"shooting": 74,
|
||||||
|
"passing": 86,
|
||||||
|
"dribbling": 86,
|
||||||
|
"defending": 24,
|
||||||
|
"physical": 58,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_177388",
|
||||||
|
"name": "Dimitri Payet",
|
||||||
|
"overall": 86,
|
||||||
|
"position": "LM",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "West Ham",
|
||||||
|
"pace": 77,
|
||||||
|
"shooting": 78,
|
||||||
|
"passing": 87,
|
||||||
|
"dribbling": 87,
|
||||||
|
"defending": 42,
|
||||||
|
"physical": 70,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_183277",
|
||||||
|
"name": "Eden Hazard",
|
||||||
|
"overall": 88,
|
||||||
|
"position": "LM",
|
||||||
|
"nation": "Belgium",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Chelsea",
|
||||||
|
"pace": 90,
|
||||||
|
"shooting": 81,
|
||||||
|
"passing": 82,
|
||||||
|
"dribbling": 91,
|
||||||
|
"defending": 32,
|
||||||
|
"physical": 64,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_184941",
|
||||||
|
"name": "Alexis Sánchez",
|
||||||
|
"overall": 87,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "Chile",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Arsenal",
|
||||||
|
"pace": 86,
|
||||||
|
"shooting": 82,
|
||||||
|
"passing": 79,
|
||||||
|
"dribbling": 88,
|
||||||
|
"defending": 39,
|
||||||
|
"physical": 74,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_190871",
|
||||||
|
"name": "Neymar",
|
||||||
|
"overall": 92,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "Brazil",
|
||||||
|
"league": "LaLiga Santander",
|
||||||
|
"club": "FC Barcelona",
|
||||||
|
"pace": 91,
|
||||||
|
"shooting": 84,
|
||||||
|
"passing": 78,
|
||||||
|
"dribbling": 95,
|
||||||
|
"defending": 30,
|
||||||
|
"physical": 56,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_192119",
|
||||||
|
"name": "Thibaut Courtois",
|
||||||
|
"overall": 89,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Belgium",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Chelsea",
|
||||||
|
"pace": 84,
|
||||||
|
"shooting": 91,
|
||||||
|
"passing": 69,
|
||||||
|
"dribbling": 89,
|
||||||
|
"defending": 48,
|
||||||
|
"physical": 86,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_192985",
|
||||||
|
"name": "Kevin De Bruyne",
|
||||||
|
"overall": 88,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Belgium",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester City",
|
||||||
|
"pace": 77,
|
||||||
|
"shooting": 83,
|
||||||
|
"passing": 86,
|
||||||
|
"dribbling": 84,
|
||||||
|
"defending": 40,
|
||||||
|
"physical": 75,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_193080",
|
||||||
|
"name": "David De Gea",
|
||||||
|
"overall": 90,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester Utd",
|
||||||
|
"pace": 88,
|
||||||
|
"shooting": 85,
|
||||||
|
"passing": 87,
|
||||||
|
"dribbling": 90,
|
||||||
|
"defending": 56,
|
||||||
|
"physical": 85,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_195864",
|
||||||
|
"name": "Paul Pogba",
|
||||||
|
"overall": 88,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester Utd",
|
||||||
|
"pace": 77,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 83,
|
||||||
|
"dribbling": 87,
|
||||||
|
"defending": 72,
|
||||||
|
"physical": 87,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_20801",
|
||||||
|
"name": "Cristiano Ronaldo",
|
||||||
|
"overall": 94,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "Portugal",
|
||||||
|
"league": "LaLiga Santander",
|
||||||
|
"club": "Real Madrid",
|
||||||
|
"pace": 92,
|
||||||
|
"shooting": 92,
|
||||||
|
"passing": 81,
|
||||||
|
"dribbling": 91,
|
||||||
|
"defending": 33,
|
||||||
|
"physical": 80,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_41236",
|
||||||
|
"name": "Zlatan Ibrahimović",
|
||||||
|
"overall": 90,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Sweden",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Manchester Utd",
|
||||||
|
"pace": 72,
|
||||||
|
"shooting": 90,
|
||||||
|
"passing": 81,
|
||||||
|
"dribbling": 85,
|
||||||
|
"defending": 31,
|
||||||
|
"physical": 86,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fifa17_48940",
|
||||||
|
"name": "Petr Čech",
|
||||||
|
"overall": 88,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Czech Republic",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Arsenal",
|
||||||
|
"pace": 83,
|
||||||
|
"shooting": 90,
|
||||||
|
"passing": 77,
|
||||||
|
"dribbling": 85,
|
||||||
|
"defending": 48,
|
||||||
|
"physical": 85,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Generic, game-scoped OPAQUE extension storage.
|
||||||
|
--
|
||||||
|
-- Core persists, versions, associates (to a canonical entity + a server-computed
|
||||||
|
-- fingerprint), and enforces generic safety bounds on these bytes — but NEVER
|
||||||
|
-- interprets them. A game adapter owns the payload's schema and meaning. This is
|
||||||
|
-- how a game keeps wire-only round-trip state (e.g. FIFA 17 squad custom[]/
|
||||||
|
-- kicktakers/kitNumber) durable and atomic with its canonical entity without
|
||||||
|
-- leaking game-specific columns into generic Core.
|
||||||
|
--
|
||||||
|
-- Scope key: (game_id, entity_kind, entity_id, namespace). `namespace` is an
|
||||||
|
-- opaque adapter key (e.g. "fifa17.squad.v1"); `schema_version` is the adapter's
|
||||||
|
-- payload version (distinct from this table's storage schema).
|
||||||
|
CREATE TABLE IF NOT EXISTS game_entity_ext (
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
entity_kind TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
schema_version INTEGER NOT NULL,
|
||||||
|
canonical_fingerprint TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (game_id, entity_kind, entity_id, namespace)
|
||||||
|
);
|
||||||
+7
-1
@@ -42,7 +42,11 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||||
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
|
let mut card_db = CardDb::load(&cfg.data_dir)?;
|
||||||
|
for game in &cfg.dev_content_games {
|
||||||
|
card_db.load_game_dev(&cfg.data_dir, game)?;
|
||||||
|
}
|
||||||
|
let card_db = Arc::new(card_db);
|
||||||
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
||||||
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
||||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||||
@@ -168,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||||
.route("/squad", get(routes::squad::get_squad))
|
.route("/squad", get(routes::squad::get_squad))
|
||||||
.route("/squad", post(routes::squad::post_squad))
|
.route("/squad", post(routes::squad::post_squad))
|
||||||
|
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
||||||
|
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
||||||
.route("/squads", get(routes::squad::get_squads))
|
.route("/squads", get(routes::squad::get_squads))
|
||||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ pub struct Config {
|
|||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
pub max_connections: u32,
|
pub max_connections: u32,
|
||||||
|
/// Games whose opt-in development content pack (`data/games/<game>/dev/`) is
|
||||||
|
/// loaded IN ADDITION to the default `data/cards` catalog. Empty by default —
|
||||||
|
/// default/test content is never affected unless a game is named here.
|
||||||
|
pub dev_content_games: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -19,6 +23,16 @@ impl Config {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(5),
|
.unwrap_or(5),
|
||||||
|
dev_content_games: std::env::var("OPENFUT_DEV_CONTENT_GAMES")
|
||||||
|
.ok()
|
||||||
|
.map(|v| {
|
||||||
|
v.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
|
|||||||
database_url: "sqlite::memory:".into(),
|
database_url: "sqlite::memory:".into(),
|
||||||
data_dir: data_dir.to_string(),
|
data_dir: data_dir.to_string(),
|
||||||
max_connections: 1,
|
max_connections: 1,
|
||||||
|
dev_content_games: Vec::new(),
|
||||||
};
|
};
|
||||||
app::build(pool, cfg).await
|
app::build(pool, cfg).await
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -16,6 +16,20 @@ async fn main() -> Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cfg = config::Config::from_env()?;
|
let cfg = config::Config::from_env()?;
|
||||||
|
|
||||||
|
// Opt-in dev subcommand: `openfut-core seed-dev` seeds the FIFA 17 dev
|
||||||
|
// profile/club from the dev content pack, prints a coverage report, and
|
||||||
|
// exits. Normal server startup NEVER seeds dev inventory.
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("seed-dev") {
|
||||||
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
|
db::run_migrations(&pool).await?;
|
||||||
|
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
|
||||||
|
card_db.load_game_dev(&cfg.data_dir, seed::FIFA17_GAME)?;
|
||||||
|
let report = seed::seed_fifa17_dev(&pool, &card_db).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||||
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
|
|||||||
@@ -27,6 +27,33 @@ impl Rarity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual card quality tier (gold/silver/bronze).
|
||||||
|
///
|
||||||
|
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
||||||
|
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
|
||||||
|
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
|
||||||
|
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Quality {
|
||||||
|
Bronze,
|
||||||
|
Silver,
|
||||||
|
Gold,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Quality {
|
||||||
|
/// Classify a base overall rating into its quality tier.
|
||||||
|
pub fn from_overall(overall: u8) -> Self {
|
||||||
|
if overall >= 75 {
|
||||||
|
Quality::Gold
|
||||||
|
} else if overall >= 65 {
|
||||||
|
Quality::Silver
|
||||||
|
} else {
|
||||||
|
Quality::Bronze
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A card definition loaded from JSON data files.
|
/// A card definition loaded from JSON data files.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CardDefinition {
|
pub struct CardDefinition {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Generic, game-scoped **opaque** extension state.
|
||||||
|
//!
|
||||||
|
//! Core stores and versions these bytes and associates them with a canonical
|
||||||
|
//! entity + a server-computed fingerprint, but never interprets them. A game
|
||||||
|
//! adapter owns the payload schema/meaning. This keeps game-only wire round-trip
|
||||||
|
//! state (e.g. a FIFA 17 squad's `custom[]`/`kicktakers`/`kitNumber`) durable and
|
||||||
|
//! atomic with its canonical entity without leaking game concepts into Core.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Generic safety bounds Core enforces without interpreting the payload.
|
||||||
|
pub const MAX_EXT_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||||
|
pub const MAX_EXT_NAMESPACE_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// An opaque extension payload a game adapter asks Core to persist atomically
|
||||||
|
/// alongside a canonical entity. `payload` is uninterpreted bytes-as-text.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct OpaqueExtensionWrite {
|
||||||
|
/// Opaque adapter key, e.g. `"fifa17.squad.v1"`. Core treats it as a string.
|
||||||
|
pub namespace: String,
|
||||||
|
/// Adapter's payload schema version (distinct from the DB storage schema).
|
||||||
|
pub schema_version: i64,
|
||||||
|
/// Uninterpreted payload (the adapter's serialized game-only state).
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpaqueExtensionWrite {
|
||||||
|
/// Generic bounds check — namespace non-empty/length, payload size. Semantic
|
||||||
|
/// validation of the payload is the adapter's job; Core only guards size.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.namespace.is_empty() || self.namespace.len() > MAX_EXT_NAMESPACE_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"namespace length {} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})",
|
||||||
|
self.namespace.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"extension payload {} bytes exceeds max {MAX_EXT_PAYLOAD_BYTES}",
|
||||||
|
self.payload.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored opaque extension row (read side). `canonical_fingerprint` is the
|
||||||
|
/// server-computed fingerprint of the canonical entity at write time; a reader
|
||||||
|
/// compares it against the entity's *current* fingerprint to detect staleness.
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct GameEntityExt {
|
||||||
|
pub game_id: String,
|
||||||
|
pub entity_kind: String,
|
||||||
|
pub entity_id: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub schema_version: i64,
|
||||||
|
pub canonical_fingerprint: String,
|
||||||
|
pub payload: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod notification;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
|
pub mod game_ext;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod season;
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
|||||||
@@ -54,3 +54,49 @@ pub struct SquadPlayerInput {
|
|||||||
pub is_captain: bool,
|
pub is_captain: bool,
|
||||||
pub is_on_bench: bool,
|
pub is_on_bench: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A complete squad, as a game client sends it.
|
||||||
|
///
|
||||||
|
/// # Why replacement rather than edits
|
||||||
|
///
|
||||||
|
/// Retail FIFA 17 sends the WHOLE squad on every save — roughly 2 KB carrying
|
||||||
|
/// every slot, item id and kit number — and a user swapping two players
|
||||||
|
/// produced nine changed slots across two saves. Slot deltas therefore do not
|
||||||
|
/// describe what the user did, and any attempt to derive `swap_players` or
|
||||||
|
/// `move_player` from them would be inventing intent the wire never carried.
|
||||||
|
///
|
||||||
|
/// So the only honest semantic operation is: *this is the squad now*.
|
||||||
|
///
|
||||||
|
/// Empty slots are simply absent from `slots`; a client that models an empty
|
||||||
|
/// slot as a zero item id must drop it at the adapter boundary rather than
|
||||||
|
/// sending a player Core would have to special-case.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SquadReplacement {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub formation: Option<String>,
|
||||||
|
pub slots: Vec<SlotAssignment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SlotAssignment {
|
||||||
|
pub owned_card_id: String,
|
||||||
|
/// Core's slot numbering. The adapter maps the game's numbering onto it.
|
||||||
|
pub slot: i64,
|
||||||
|
pub is_captain: bool,
|
||||||
|
pub is_on_bench: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a replacement.
|
||||||
|
///
|
||||||
|
/// Carries the server's own evaluation and, separately, any disagreement with
|
||||||
|
/// what the client claimed — never a merged value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SquadReplaced {
|
||||||
|
pub squad: Squad,
|
||||||
|
pub slots_written: usize,
|
||||||
|
pub evaluation: crate::services::squad_rules::SquadEvaluation,
|
||||||
|
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
|
||||||
|
/// Server-computed deterministic fingerprint of the committed canonical squad
|
||||||
|
/// (anchors any opaque game extension against stale projection).
|
||||||
|
pub canonical_fingerprint: String,
|
||||||
|
}
|
||||||
|
|||||||
+32
-8
@@ -10,7 +10,11 @@ use crate::{
|
|||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::OwnedCard,
|
models::card::OwnedCard,
|
||||||
services::{club as club_svc, profile as profile_svc},
|
services::{
|
||||||
|
club as club_svc,
|
||||||
|
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||||
|
profile as profile_svc,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Quick-sell value for a card based on overall rating.
|
/// Quick-sell value for a card based on overall rating.
|
||||||
@@ -94,7 +98,11 @@ pub async fn get_cards(
|
|||||||
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
|
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Query(query): Query<OwnedItemQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -105,14 +113,14 @@ pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let with_defs: Vec<Value> = owned
|
let views: Vec<OwnedItemView> = owned
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|o| {
|
.filter_map(|o| {
|
||||||
state.card_db.get(&o.card_id).map(|def| {
|
state.card_db.get(&o.card_id).map(|def| {
|
||||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||||
let effective_position =
|
let effective_position =
|
||||||
o.position_override.as_deref().unwrap_or(&def.position);
|
o.position_override.as_deref().unwrap_or(&def.position);
|
||||||
json!({
|
let body = json!({
|
||||||
"owned_card_id": o.id,
|
"owned_card_id": o.id,
|
||||||
"is_loan": o.is_loan,
|
"is_loan": o.is_loan,
|
||||||
"loan_matches_remaining": o.loan_matches_remaining,
|
"loan_matches_remaining": o.loan_matches_remaining,
|
||||||
@@ -123,14 +131,30 @@ pub async fn get_collection(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
"effective_overall": effective_overall,
|
"effective_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
"card": def,
|
"card": def,
|
||||||
})
|
});
|
||||||
|
OwnedItemView {
|
||||||
|
owned_card_id: o.id.clone(),
|
||||||
|
base_overall: def.overall,
|
||||||
|
effective_overall,
|
||||||
|
position: effective_position.to_string(),
|
||||||
|
nation: def.nation.clone(),
|
||||||
|
league: def.league.clone(),
|
||||||
|
club: def.club.clone(),
|
||||||
|
body,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(
|
let page = inventory::apply_query(views, &query);
|
||||||
json!({ "collection": with_defs, "total": with_defs.len() }),
|
let returned = page.items.len();
|
||||||
))
|
Ok(Json(json!({
|
||||||
|
"collection": page.items,
|
||||||
|
"total": page.total,
|
||||||
|
"returned": returned,
|
||||||
|
"offset": page.offset,
|
||||||
|
"limit": page.limit,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||||
|
|||||||
+142
-5
@@ -1,15 +1,21 @@
|
|||||||
use crate::extractors::GameId;
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::squad::SaveSquadRequest,
|
models::game_ext::OpaqueExtensionWrite,
|
||||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement},
|
||||||
|
services::{
|
||||||
|
club as club_svc, profile as profile_svc, squad as squad_svc,
|
||||||
|
squad::SquadExtState,
|
||||||
|
squad_rules::{ClientReportedEvaluation, DefaultSquadRules},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
@@ -55,7 +61,7 @@ pub async fn post_squad(
|
|||||||
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?;
|
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
Ok(Json(json!({ "squad": squad })))
|
Ok(Json(json!({ "squad": squad })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,3 +104,134 @@ fn squad_response(
|
|||||||
"chemistry": chemistry,
|
"chemistry": chemistry,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────── Game-extension-aware squad transport (host composition) ─────────
|
||||||
|
//
|
||||||
|
// These two routes expose the already-existing extension services
|
||||||
|
// (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game
|
||||||
|
// host can read/write the canonical squad AND its opaque game extension in one
|
||||||
|
// Core round-trip. They add no domain logic — Core still owns validation,
|
||||||
|
// ownership, the atomic transaction, the server fingerprint, and staleness; it
|
||||||
|
// never interprets the extension payload.
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ExtQuery {
|
||||||
|
/// Opaque adapter namespace, e.g. `"fifa17.squad"`.
|
||||||
|
pub namespace: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque
|
||||||
|
/// extension with an explicit Fresh/Stale/Missing verdict. Never projects a
|
||||||
|
/// stale blob; the caller decides policy.
|
||||||
|
pub async fn get_squad_ext(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Query(q): Query<ExtQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
let (squad, players, state_ext) =
|
||||||
|
squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?;
|
||||||
|
|
||||||
|
let extension = match state_ext {
|
||||||
|
SquadExtState::Fresh(row) => json!({
|
||||||
|
"state": "fresh",
|
||||||
|
"schema_version": row.schema_version,
|
||||||
|
"payload": row.payload,
|
||||||
|
"stored_fingerprint": row.canonical_fingerprint,
|
||||||
|
}),
|
||||||
|
SquadExtState::Stale {
|
||||||
|
stored,
|
||||||
|
current_fingerprint,
|
||||||
|
} => json!({
|
||||||
|
"state": "stale",
|
||||||
|
"schema_version": stored.schema_version,
|
||||||
|
"payload": stored.payload,
|
||||||
|
"stored_fingerprint": stored.canonical_fingerprint,
|
||||||
|
"current_fingerprint": current_fingerprint,
|
||||||
|
}),
|
||||||
|
SquadExtState::Missing => json!({ "state": "missing" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"squad": squad,
|
||||||
|
"players": players,
|
||||||
|
"extension": extension,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SlotReq {
|
||||||
|
pub owned_card_id: String,
|
||||||
|
pub slot: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_captain: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_on_bench: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ReplaceReq {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub formation: Option<String>,
|
||||||
|
pub slots: Vec<SlotReq>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_reported: ClientReportedEvaluation,
|
||||||
|
pub extension: OpaqueExtensionWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /squad/replace` — full-replacement of the active squad's canonical slots
|
||||||
|
/// plus its opaque game extension, in ONE Core transaction. Resolves the active
|
||||||
|
/// squad in place (creates one if none exists). Ownership, duplicate, and size
|
||||||
|
/// validation happen inside the service before any write.
|
||||||
|
pub async fn put_squad_replace(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<ReplaceReq>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
// Replace the club's active squad in place; if there is none yet, create it.
|
||||||
|
let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await {
|
||||||
|
Ok((s, _)) => Some(s.id),
|
||||||
|
Err(AppError::NotFound(_)) => None,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let replacement = SquadReplacement {
|
||||||
|
name: req.name,
|
||||||
|
formation: req.formation,
|
||||||
|
slots: req
|
||||||
|
.slots
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| SlotAssignment {
|
||||||
|
owned_card_id: s.owned_card_id,
|
||||||
|
slot: s.slot,
|
||||||
|
is_captain: s.is_captain,
|
||||||
|
is_on_bench: s.is_on_bench,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = squad_svc::replace_squad_with_extension(
|
||||||
|
&state.pool,
|
||||||
|
&state.card_db,
|
||||||
|
&DefaultSquadRules,
|
||||||
|
game.as_str(),
|
||||||
|
&club.id,
|
||||||
|
squad_id.as_deref(),
|
||||||
|
&replacement,
|
||||||
|
&req.client_reported,
|
||||||
|
&req.extension,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"squad_id": out.squad.id,
|
||||||
|
"canonical_fingerprint": out.canonical_fingerprint,
|
||||||
|
"slots_written": out.slots_written,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
+186
-1
@@ -1,6 +1,23 @@
|
|||||||
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::AppResult,
|
||||||
|
models::{card::Quality, club::Club, pack::PackDefinition},
|
||||||
|
services::{card_db::CardDb, club as club_svc, pack as pack_svc, profile as profile_svc},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
/// The game whose dev content + inventory this seeds.
|
||||||
|
pub const FIFA17_GAME: &str = "fifa17";
|
||||||
|
/// Deterministic owned-instance id prefix, so re-running the seed is idempotent
|
||||||
|
/// (INSERT OR IGNORE on a stable id) rather than minting duplicate ownership.
|
||||||
|
const DEV_OWNED_PREFIX: &str = "fdev-";
|
||||||
|
/// Fixed grant timestamp — the seed is deterministic, not wall-clock dependent.
|
||||||
|
const DEV_ACQUIRED_AT: &str = "2026-08-11T00:00:00Z";
|
||||||
|
/// The client's My Squad page size (evidence: request `count=11`).
|
||||||
|
const MY_SQUAD_PAGE: usize = 11;
|
||||||
|
|
||||||
/// Seeds the market with NPC listings if empty.
|
/// Seeds the market with NPC listings if empty.
|
||||||
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
|
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
|
||||||
// Any one-time startup seeds go here.
|
// Any one-time startup seeds go here.
|
||||||
@@ -29,3 +46,171 @@ pub async fn grant_starter_pack(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── FIFA 17 dev seed ─────────────────────────────
|
||||||
|
|
||||||
|
/// Coverage of the seeded FIFA 17 development inventory. Game-independent: it
|
||||||
|
/// counts quality tiers, positions and distinct entities, and whether the Gold
|
||||||
|
/// filter spans more than one page — everything the retail `/club` UI must
|
||||||
|
/// exercise. It carries NO FIFA wire ids (those are the adapter/host's runtime
|
||||||
|
/// concern; the seed never allocates them).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DevSeedReport {
|
||||||
|
pub game_id: String,
|
||||||
|
/// True if the fifa17 club already owned dev cards (no new grants made).
|
||||||
|
pub already_seeded: bool,
|
||||||
|
pub definitions_available: usize,
|
||||||
|
pub owned_total: usize,
|
||||||
|
pub unique_definitions: usize,
|
||||||
|
pub gold: usize,
|
||||||
|
pub silver: usize,
|
||||||
|
pub bronze: usize,
|
||||||
|
pub positions: BTreeMap<String, usize>,
|
||||||
|
pub distinct_nations: usize,
|
||||||
|
pub distinct_leagues: usize,
|
||||||
|
pub distinct_clubs: usize,
|
||||||
|
pub max_same_club: usize,
|
||||||
|
/// Gold owned items exceed one page → the client must request a 2nd page.
|
||||||
|
pub gold_over_one_page: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opt-in development seed: create (if absent) a `game_id=fifa17` profile + club
|
||||||
|
/// and grant Core-owned instances of every dev-pack `CardDefinition` (ids
|
||||||
|
/// `fifa17_*`), plus one deliberate duplicate of a single definition (to exercise
|
||||||
|
/// two-copies-of-one-card identity later).
|
||||||
|
///
|
||||||
|
/// **Ownership only — no FIFA wire ids.** The FIFA 17 integer item id is minted
|
||||||
|
/// lazily by `Fifa17IdentityResolver` at request time, never here. This keeps the
|
||||||
|
/// boundary clean: Core owns "this profile owns this card"; the adapter owns
|
||||||
|
/// "this owned item is wire id N".
|
||||||
|
///
|
||||||
|
/// Idempotent: owned ids are deterministic (`fdev-<card_id>`), inserted with
|
||||||
|
/// `INSERT OR IGNORE`, so re-running grants nothing new. The default profile
|
||||||
|
/// (`fifa23`/no-header) and any existing synthetic inventory are never touched.
|
||||||
|
pub async fn seed_fifa17_dev(pool: &Pool, card_db: &CardDb) -> AppResult<DevSeedReport> {
|
||||||
|
// The dev definitions are exactly the game-namespaced ids in the catalog.
|
||||||
|
let mut defs: Vec<&crate::models::card::CardDefinition> = card_db
|
||||||
|
.cards
|
||||||
|
.values()
|
||||||
|
.filter(|c| c.id.starts_with("fifa17_"))
|
||||||
|
.collect();
|
||||||
|
defs.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
|
||||||
|
// Ensure the fifa17-scoped profile + club exist (single-profile-per-game).
|
||||||
|
let profile = match profile_svc::get_active_profile(pool, FIFA17_GAME).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => profile_svc::create_profile(pool, "OpenFUT Dev (FIFA17)", FIFA17_GAME).await?,
|
||||||
|
};
|
||||||
|
let club = match club_svc::get_club_by_profile(pool, &profile.id).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
let c = Club::new(&profile.id, "OpenFUT Dev FC", 100_000);
|
||||||
|
club_svc::create_club(pool, &c).await?;
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let prior: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
|
||||||
|
)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let already_seeded = prior > 0;
|
||||||
|
|
||||||
|
// Grant one instance per definition; INSERT OR IGNORE keeps reruns idempotent.
|
||||||
|
for def in &defs {
|
||||||
|
grant_owned(
|
||||||
|
pool,
|
||||||
|
&format!("{DEV_OWNED_PREFIX}{}", def.id),
|
||||||
|
&club.id,
|
||||||
|
&def.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
// One deliberate duplicate of the first (lexicographic) definition → two
|
||||||
|
// owned copies of one card sharing a definition but distinct owned ids.
|
||||||
|
if let Some(first) = defs.first() {
|
||||||
|
grant_owned(
|
||||||
|
pool,
|
||||||
|
&format!("{DEV_OWNED_PREFIX}{}-b", first.id),
|
||||||
|
&club.id,
|
||||||
|
&first.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = dev_coverage(pool, card_db, &club.id, already_seeded, defs.len()).await?;
|
||||||
|
info!(
|
||||||
|
"seeded fifa17 dev inventory: {} owned ({} gold) over club {}",
|
||||||
|
report.owned_total, report.gold, club.id
|
||||||
|
);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_owned(pool: &Pool, owned_id: &str, club_id: &str, card_id: &str) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO owned_cards \
|
||||||
|
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(owned_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(card_id)
|
||||||
|
.bind(DEV_ACQUIRED_AT)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the coverage report from the club's owned dev cards joined to `card_db`.
|
||||||
|
async fn dev_coverage(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
club_id: &str,
|
||||||
|
already_seeded: bool,
|
||||||
|
definitions_available: usize,
|
||||||
|
) -> AppResult<DevSeedReport> {
|
||||||
|
let card_ids: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT card_id FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let (mut gold, mut silver, mut bronze) = (0usize, 0usize, 0usize);
|
||||||
|
let mut positions: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut nations = std::collections::BTreeSet::new();
|
||||||
|
let mut leagues = std::collections::BTreeSet::new();
|
||||||
|
let mut club_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut unique = std::collections::BTreeSet::new();
|
||||||
|
for card_id in &card_ids {
|
||||||
|
unique.insert(card_id.clone());
|
||||||
|
if let Some(def) = card_db.get(card_id) {
|
||||||
|
match Quality::from_overall(def.overall) {
|
||||||
|
Quality::Gold => gold += 1,
|
||||||
|
Quality::Silver => silver += 1,
|
||||||
|
Quality::Bronze => bronze += 1,
|
||||||
|
}
|
||||||
|
*positions.entry(def.position.clone()).or_default() += 1;
|
||||||
|
nations.insert(def.nation.clone());
|
||||||
|
leagues.insert(def.league.clone());
|
||||||
|
*club_counts.entry(def.club.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DevSeedReport {
|
||||||
|
game_id: FIFA17_GAME.to_string(),
|
||||||
|
already_seeded,
|
||||||
|
definitions_available,
|
||||||
|
owned_total: card_ids.len(),
|
||||||
|
unique_definitions: unique.len(),
|
||||||
|
gold,
|
||||||
|
silver,
|
||||||
|
bronze,
|
||||||
|
positions,
|
||||||
|
distinct_nations: nations.len(),
|
||||||
|
distinct_leagues: leagues.len(),
|
||||||
|
distinct_clubs: club_counts.len(),
|
||||||
|
max_same_club: club_counts.values().copied().max().unwrap_or(0),
|
||||||
|
gold_over_one_page: gold > MY_SQUAD_PAGE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,30 @@ impl CardDb {
|
|||||||
Ok(Self { cards })
|
Ok(Self { cards })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge a game's **opt-in development content pack** from
|
||||||
|
/// `{data_dir}/games/{game}/dev/cards.json` (a single `CardDefinition[]`).
|
||||||
|
/// This is NOT read by [`CardDb::load`]; it is loaded only when a game is
|
||||||
|
/// explicitly named in `Config::dev_content_games`, so default content stays
|
||||||
|
/// untouched. Returns the number of definitions merged. A missing file is an
|
||||||
|
/// error (opt-in means the pack is expected to exist).
|
||||||
|
pub fn load_game_dev(&mut self, data_dir: &str, game: &str) -> Result<usize> {
|
||||||
|
let path = Path::new(data_dir)
|
||||||
|
.join("games")
|
||||||
|
.join(game)
|
||||||
|
.join("dev")
|
||||||
|
.join("cards.json");
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("reading dev content pack {path:?}"))?;
|
||||||
|
let batch: Vec<CardDefinition> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||||
|
let n = batch.len();
|
||||||
|
for card in batch {
|
||||||
|
self.cards.insert(card.id.clone(), card);
|
||||||
|
}
|
||||||
|
tracing::info!("Loaded {} dev card definitions for game '{}'", n, game);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||||
self.cards.get(id)
|
self.cards.get(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Generic read/write for [`crate::models::game_ext`] opaque state.
|
||||||
|
//!
|
||||||
|
//! Core never interprets the payload. Writes happen INSIDE the owning entity's
|
||||||
|
//! transaction (see `squad::replace_squad_with_extension`) so the canonical
|
||||||
|
//! entity and its opaque extension commit atomically — there is deliberately no
|
||||||
|
//! standalone "write extension" entry point that could desync the two.
|
||||||
|
|
||||||
|
use crate::db::Pool;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::models::game_ext::GameEntityExt;
|
||||||
|
|
||||||
|
/// Fetch the stored opaque extension for a scoped entity, or `None`. The caller
|
||||||
|
/// compares `canonical_fingerprint` against the entity's *current* fingerprint to
|
||||||
|
/// decide freshness — this layer does not know how to fingerprint any entity.
|
||||||
|
pub async fn get_ext(
|
||||||
|
pool: &Pool,
|
||||||
|
game_id: &str,
|
||||||
|
entity_kind: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
namespace: &str,
|
||||||
|
) -> AppResult<Option<GameEntityExt>> {
|
||||||
|
let row = sqlx::query_as::<_, GameEntityExt>(
|
||||||
|
"SELECT game_id, entity_kind, entity_id, namespace, schema_version, \
|
||||||
|
canonical_fingerprint, payload, updated_at FROM game_entity_ext \
|
||||||
|
WHERE game_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ?",
|
||||||
|
)
|
||||||
|
.bind(game_id)
|
||||||
|
.bind(entity_kind)
|
||||||
|
.bind(entity_id)
|
||||||
|
.bind(namespace)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
Executable
+291
@@ -0,0 +1,291 @@
|
|||||||
|
//! Game-independent owned-inventory query: semantic filtering, deterministic
|
||||||
|
//! ordering, and offset/limit pagination over a club's owned items.
|
||||||
|
//!
|
||||||
|
//! This layer is deliberately free of any game-specific concepts. It never sees
|
||||||
|
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
|
||||||
|
//! responsible for translating its wire query into the *semantic* values here
|
||||||
|
//! (quality tier, entity **names**, semantic offset/limit). The canonical
|
||||||
|
//! ordering is imposed by Core so pagination is correct and repeatable
|
||||||
|
//! regardless of what (if any) sort the client requests; see the module tests
|
||||||
|
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
|
||||||
|
//!
|
||||||
|
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
|
||||||
|
//! before filtering is the production bug this replaces (a client that pages an
|
||||||
|
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::models::card::Quality;
|
||||||
|
|
||||||
|
/// Semantic owned-inventory query. All values are game-independent: a quality
|
||||||
|
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
|
||||||
|
/// optional; combined filters are ANDed. Absent field = no constraint.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct OwnedItemQuery {
|
||||||
|
/// Quality tier (gold/silver/bronze). Serialized lowercase.
|
||||||
|
#[serde(default)]
|
||||||
|
pub quality: Option<Quality>,
|
||||||
|
/// Playing position, e.g. "ST" (matched case-insensitively).
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: Option<String>,
|
||||||
|
/// Nation name, e.g. "Argentina" (matched case-insensitively).
|
||||||
|
#[serde(default)]
|
||||||
|
pub nation: Option<String>,
|
||||||
|
/// League name, e.g. "Premier League" (matched case-insensitively).
|
||||||
|
#[serde(default)]
|
||||||
|
pub league: Option<String>,
|
||||||
|
/// Club name, e.g. "Chelsea" (matched case-insensitively).
|
||||||
|
#[serde(default)]
|
||||||
|
pub club: Option<String>,
|
||||||
|
/// Number of leading items to skip after filtering + ordering.
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
/// Maximum number of items to return in the page.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One owned item projected to the attributes needed for querying, plus the
|
||||||
|
/// response body to hand back verbatim once it survives the filter+page.
|
||||||
|
pub struct OwnedItemView {
|
||||||
|
pub owned_card_id: String,
|
||||||
|
/// Base card overall (drives quality tier).
|
||||||
|
pub base_overall: u8,
|
||||||
|
/// Effective overall (base + training bonus); drives ordering.
|
||||||
|
pub effective_overall: i64,
|
||||||
|
pub position: String,
|
||||||
|
pub nation: String,
|
||||||
|
pub league: String,
|
||||||
|
pub club: String,
|
||||||
|
pub body: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnedItemView {
|
||||||
|
fn quality(&self) -> Quality {
|
||||||
|
Quality::from_overall(self.base_overall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of applying a query: the requested page plus the count of items that
|
||||||
|
/// matched the filter **before** pagination (what a client needs to page).
|
||||||
|
pub struct QueryPage {
|
||||||
|
pub items: Vec<serde_json::Value>,
|
||||||
|
pub total: usize,
|
||||||
|
pub offset: usize,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does an item satisfy every present filter (AND semantics)?
|
||||||
|
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||||
|
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
|
||||||
|
let pos_ok = q
|
||||||
|
.position
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| item.position.eq_ignore_ascii_case(p))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let nation_ok = q
|
||||||
|
.nation
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| item.nation.eq_ignore_ascii_case(n))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let league_ok = q
|
||||||
|
.league
|
||||||
|
.as_ref()
|
||||||
|
.map(|l| item.league.eq_ignore_ascii_case(l))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let club_ok = q
|
||||||
|
.club
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| item.club.eq_ignore_ascii_case(c))
|
||||||
|
.unwrap_or(true);
|
||||||
|
quality_ok && pos_ok && nation_ok && league_ok && club_ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the query: filter (AND) → deterministic order → paginate.
|
||||||
|
///
|
||||||
|
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
|
||||||
|
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
|
||||||
|
/// non-negative values (the wire never sends negatives; clamping keeps a
|
||||||
|
/// malformed request from panicking).
|
||||||
|
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
|
||||||
|
// 1. filter
|
||||||
|
items.retain(|it| matches(it, q));
|
||||||
|
let total = items.len();
|
||||||
|
|
||||||
|
// 2. deterministic total order (independent of input/DB order)
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
b.effective_overall
|
||||||
|
.cmp(&a.effective_overall)
|
||||||
|
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. paginate
|
||||||
|
let offset = q.offset.unwrap_or(0).max(0) as usize;
|
||||||
|
let limit = q.limit.map(|l| l.max(0) as usize);
|
||||||
|
let page: Vec<serde_json::Value> = items
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit.unwrap_or(usize::MAX))
|
||||||
|
.map(|it| it.body)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
QueryPage {
|
||||||
|
items: page,
|
||||||
|
total,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn view(
|
||||||
|
id: &str,
|
||||||
|
overall: u8,
|
||||||
|
position: &str,
|
||||||
|
nation: &str,
|
||||||
|
league: &str,
|
||||||
|
club: &str,
|
||||||
|
) -> OwnedItemView {
|
||||||
|
OwnedItemView {
|
||||||
|
owned_card_id: id.to_string(),
|
||||||
|
base_overall: overall,
|
||||||
|
effective_overall: overall as i64,
|
||||||
|
position: position.to_string(),
|
||||||
|
nation: nation.to_string(),
|
||||||
|
league: league.to_string(),
|
||||||
|
club: club.to_string(),
|
||||||
|
body: json!({ "owned_card_id": id, "overall": overall }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(page: &QueryPage) -> Vec<String> {
|
||||||
|
page.items
|
||||||
|
.iter()
|
||||||
|
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Vec<OwnedItemView> {
|
||||||
|
vec![
|
||||||
|
view("a", 84, "ST", "England", "Premier League", "Northgate"),
|
||||||
|
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
|
||||||
|
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
|
||||||
|
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
|
||||||
|
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_filter_returns_all_in_overall_desc_order() {
|
||||||
|
let p = apply_query(fixture(), &OwnedItemQuery::default());
|
||||||
|
assert_eq!(p.total, 5);
|
||||||
|
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quality_gold_selects_overall_75_plus() {
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
quality: Some(Quality::Gold),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert_eq!(ids(&p), ["e", "b", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_are_anded() {
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
league: Some("Premier League".into()),
|
||||||
|
position: Some("ST".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_insensitive_name_match() {
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
club: Some("chelsea".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert_eq!(ids(&p), ["b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_when_nothing_matches() {
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
nation: Some("Argentina".into()),
|
||||||
|
club: Some("Chelsea".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert_eq!(p.total, 0);
|
||||||
|
assert!(p.items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_runs_before_pagination() {
|
||||||
|
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
|
||||||
|
// If pagination ran first, offset/limit would slice the full 5-item set.
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
quality: Some(Quality::Gold),
|
||||||
|
offset: Some(1),
|
||||||
|
limit: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
|
||||||
|
assert_eq!(ids(&p), ["b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pages_do_not_overlap_and_advance() {
|
||||||
|
let page = |off| {
|
||||||
|
apply_query(
|
||||||
|
fixture(),
|
||||||
|
&OwnedItemQuery {
|
||||||
|
offset: Some(off),
|
||||||
|
limit: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let p0 = page(0);
|
||||||
|
let p1 = page(2);
|
||||||
|
assert_eq!(ids(&p0), ["e", "b"]);
|
||||||
|
assert_eq!(ids(&p1), ["a", "c"]);
|
||||||
|
// start advancing must NOT re-serve page one
|
||||||
|
assert_ne!(ids(&p0), ids(&p1));
|
||||||
|
assert_eq!(p0.total, 5);
|
||||||
|
assert_eq!(p1.total, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offset_past_end_is_empty_not_wrapped() {
|
||||||
|
let q = OwnedItemQuery {
|
||||||
|
offset: Some(100),
|
||||||
|
limit: Some(11),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let p = apply_query(fixture(), &q);
|
||||||
|
assert!(p.items.is_empty());
|
||||||
|
assert_eq!(p.total, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordering_is_stable_on_overall_ties() {
|
||||||
|
let items = vec![
|
||||||
|
view("z", 80, "ST", "N", "L", "C"),
|
||||||
|
view("a", 80, "ST", "N", "L", "C"),
|
||||||
|
view("m", 80, "ST", "N", "L", "C"),
|
||||||
|
];
|
||||||
|
let p = apply_query(items, &OwnedItemQuery::default());
|
||||||
|
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ pub mod notification;
|
|||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
|
pub mod game_ext;
|
||||||
|
pub mod inventory;
|
||||||
pub mod season;
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
@@ -15,5 +17,6 @@ pub mod profile;
|
|||||||
pub mod sbc;
|
pub mod sbc;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
|
pub mod squad_rules;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
pub mod upgrades;
|
pub mod upgrades;
|
||||||
|
|||||||
+913
-55
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
|||||||
|
//! Squad evaluation, behind a game-rules boundary.
|
||||||
|
//!
|
||||||
|
//! # Why this is a trait and not a function in `squad.rs`
|
||||||
|
//!
|
||||||
|
//! Chemistry, rating and star rating are **game-specific**. FUT chemistry
|
||||||
|
//! changed substantially between FIFA generations, so a single formula
|
||||||
|
//! compiled into generic Core would quietly make Core a FIFA-something server.
|
||||||
|
//! Core is allowed to understand that a squad *has* an evaluation; it is not
|
||||||
|
//! allowed to know how any particular game computes one.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Core owns the squad, slots, items, persistence
|
||||||
|
//! | and the SEMANTIC concept of an evaluation
|
||||||
|
//! v
|
||||||
|
//! SquadRules how a specific game computes it
|
||||||
|
//! |
|
||||||
|
//! +-- DefaultSquadRules OpenFUT's own rules (the implementation that
|
||||||
|
//! | already existed in Core)
|
||||||
|
//! +-- Fifa17SquadRules NOT YET WRITTEN. The FIFA 17 algorithm is not
|
||||||
|
//! proven, and inventing one would be worse than
|
||||||
|
//! having none.
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Pure data in, evaluation out
|
||||||
|
//!
|
||||||
|
//! Rules take a [`SquadSnapshot`] — already resolved by Core from the database
|
||||||
|
//! — rather than a pool and a card database. That keeps every rules
|
||||||
|
//! implementation synchronous, dependency-free and testable without fixtures,
|
||||||
|
//! and it stops a game's rules from reaching into Core's storage.
|
||||||
|
//!
|
||||||
|
//! # Client-reported values are not evaluations
|
||||||
|
//!
|
||||||
|
//! FIFA 17 sends its own `chemistry`, `rating` and `starRating` on every squad
|
||||||
|
//! save. Those are observations about what the client believes, captured for
|
||||||
|
//! shadow validation, and they are deliberately a *different type* from
|
||||||
|
//! [`SquadEvaluation`] so no later code can pass one where the other belongs.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One player in a squad, reduced to the attributes rules are allowed to see.
|
||||||
|
///
|
||||||
|
/// Deliberately not `OwnedCard` + `CardDefinition`: rules should not be able to
|
||||||
|
/// reach storage identifiers, loan state or acquisition history.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SquadPlayerCard {
|
||||||
|
/// Core's owned-card id. Present so an evaluation can attribute per-player
|
||||||
|
/// results; rules must not interpret its contents.
|
||||||
|
pub owned_card_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub overall: u8,
|
||||||
|
pub position: String,
|
||||||
|
pub nation: String,
|
||||||
|
pub league: String,
|
||||||
|
pub club: String,
|
||||||
|
/// Slot this player occupies, in Core's numbering.
|
||||||
|
pub slot: i64,
|
||||||
|
pub on_bench: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a rules implementation may consider.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SquadSnapshot {
|
||||||
|
pub formation: String,
|
||||||
|
pub players: Vec<SquadPlayerCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SquadSnapshot {
|
||||||
|
pub fn starters(&self) -> impl Iterator<Item = &SquadPlayerCard> {
|
||||||
|
self.players.iter().filter(|p| !p.on_bench)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The semantic result Core understands.
|
||||||
|
///
|
||||||
|
/// `chemistry` has no fixed scale here on purpose — `chemistry_max` travels
|
||||||
|
/// with it, because a later game may not use 100.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SquadEvaluation {
|
||||||
|
pub chemistry: i64,
|
||||||
|
pub chemistry_max: i64,
|
||||||
|
pub rating: i64,
|
||||||
|
pub star_rating: i64,
|
||||||
|
/// Per-player detail, for UIs and for diagnosing a rules mismatch.
|
||||||
|
pub players: Vec<PlayerEvaluation>,
|
||||||
|
/// Which rules produced this, so a stored or logged evaluation is never
|
||||||
|
/// ambiguous about its own provenance.
|
||||||
|
pub rules: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerEvaluation {
|
||||||
|
pub owned_card_id: String,
|
||||||
|
pub chemistry: i64,
|
||||||
|
pub detail: Vec<(String, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a game client claimed about a squad it sent.
|
||||||
|
///
|
||||||
|
/// **Never canonical.** A separate type from [`SquadEvaluation`] specifically so
|
||||||
|
/// that assigning one to the other does not compile. A modified client can put
|
||||||
|
/// anything here; OpenFUT has no independent knowledge of what it means until
|
||||||
|
/// its own rules run.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClientReportedEvaluation {
|
||||||
|
pub client_reported_chemistry: Option<i64>,
|
||||||
|
pub client_reported_rating: Option<i64>,
|
||||||
|
pub client_reported_star_rating: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of comparing what the client claimed against what the server derived.
|
||||||
|
///
|
||||||
|
/// A mismatch is **not** silently reconciled in either direction: the server's
|
||||||
|
/// value stands as canonical and the disagreement is reported so the rules
|
||||||
|
/// model can be investigated against the exact squad that produced it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EvaluationComparison {
|
||||||
|
pub field: String,
|
||||||
|
pub client: i64,
|
||||||
|
pub server: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientReportedEvaluation {
|
||||||
|
/// Fields where the client and the server disagree. Empty means agreement
|
||||||
|
/// on every field the client actually sent.
|
||||||
|
pub fn compare(&self, server: &SquadEvaluation) -> Vec<EvaluationComparison> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut check = |field: &str, client: Option<i64>, srv: i64| {
|
||||||
|
if let Some(c) = client {
|
||||||
|
if c != srv {
|
||||||
|
out.push(EvaluationComparison {
|
||||||
|
field: field.to_string(),
|
||||||
|
client: c,
|
||||||
|
server: srv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
"chemistry",
|
||||||
|
self.client_reported_chemistry,
|
||||||
|
server.chemistry,
|
||||||
|
);
|
||||||
|
check("rating", self.client_reported_rating, server.rating);
|
||||||
|
check(
|
||||||
|
"star_rating",
|
||||||
|
self.client_reported_star_rating,
|
||||||
|
server.star_rating,
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a specific game evaluates a squad.
|
||||||
|
pub trait SquadRules: Send + Sync {
|
||||||
|
/// Stable identifier recorded in [`SquadEvaluation::rules`].
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenFUT's own rules — the implementation that already lived in Core.
|
||||||
|
///
|
||||||
|
/// Moved here unchanged in behaviour rather than rewritten: it is the default
|
||||||
|
/// for clients that have no game-specific rules, and changing its numbers while
|
||||||
|
/// relocating it would have made the move unreviewable.
|
||||||
|
///
|
||||||
|
/// Link scoring: club +3 each (max 6), league +1 each (max 4), nation +1 each
|
||||||
|
/// (max 3), per player capped at 10, team total capped at 100.
|
||||||
|
pub struct DefaultSquadRules;
|
||||||
|
|
||||||
|
impl SquadRules for DefaultSquadRules {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"openfut-default-v2"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation {
|
||||||
|
let starters: Vec<&SquadPlayerCard> = snapshot.starters().collect();
|
||||||
|
|
||||||
|
let mut players = Vec::with_capacity(starters.len());
|
||||||
|
let mut total: i64 = 0;
|
||||||
|
|
||||||
|
for (i, p) in starters.iter().enumerate() {
|
||||||
|
let count = |f: fn(&SquadPlayerCard) -> &String, v: &String| {
|
||||||
|
starters
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(j, o)| *j != i && f(o) == v)
|
||||||
|
.count() as i64
|
||||||
|
};
|
||||||
|
let club_links = count(|c| &c.club, &p.club);
|
||||||
|
let league_links = count(|c| &c.league, &p.league);
|
||||||
|
let nation_links = count(|c| &c.nation, &p.nation);
|
||||||
|
|
||||||
|
let club_pts = (club_links * 3).min(6);
|
||||||
|
let league_pts = league_links.min(4);
|
||||||
|
let nation_pts = nation_links.min(3);
|
||||||
|
let chem = (club_pts + league_pts + nation_pts).min(10);
|
||||||
|
total += chem;
|
||||||
|
|
||||||
|
players.push(PlayerEvaluation {
|
||||||
|
owned_card_id: p.owned_card_id.clone(),
|
||||||
|
chemistry: chem,
|
||||||
|
detail: vec![
|
||||||
|
("club_links".into(), club_links),
|
||||||
|
("league_links".into(), league_links),
|
||||||
|
("nation_links".into(), nation_links),
|
||||||
|
("club_pts".into(), club_pts),
|
||||||
|
("league_pts".into(), league_pts),
|
||||||
|
("nation_pts".into(), nation_pts),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mean overall of the starters, rounded down. Empty squad rates 0
|
||||||
|
// rather than dividing by zero.
|
||||||
|
let rating = if starters.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
starters.iter().map(|p| p.overall as i64).sum::<i64>() / starters.len() as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
SquadEvaluation {
|
||||||
|
chemistry: total.min(100),
|
||||||
|
chemistry_max: 100,
|
||||||
|
rating,
|
||||||
|
// 0-5 from the rating band. Coarse on purpose: this is OpenFUT's
|
||||||
|
// own presentation value, not a reconstruction of any game's.
|
||||||
|
star_rating: match rating {
|
||||||
|
0 => 0,
|
||||||
|
1..=64 => 1,
|
||||||
|
65..=74 => 2,
|
||||||
|
75..=81 => 3,
|
||||||
|
82..=87 => 4,
|
||||||
|
_ => 5,
|
||||||
|
},
|
||||||
|
players,
|
||||||
|
rules: self.name().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn p(slot: i64, club: &str, league: &str, nation: &str, overall: u8) -> SquadPlayerCard {
|
||||||
|
SquadPlayerCard {
|
||||||
|
owned_card_id: format!("owned-{slot}"),
|
||||||
|
card_id: format!("card-{slot}"),
|
||||||
|
name: format!("P{slot}"),
|
||||||
|
overall,
|
||||||
|
position: "ST".into(),
|
||||||
|
nation: nation.into(),
|
||||||
|
league: league.into(),
|
||||||
|
club: club.into(),
|
||||||
|
slot,
|
||||||
|
on_bench: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_squad_evaluates_without_dividing_by_zero() {
|
||||||
|
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
|
||||||
|
assert_eq!(e.chemistry, 0);
|
||||||
|
assert_eq!(e.rating, 0);
|
||||||
|
assert_eq!(e.star_rating, 0);
|
||||||
|
assert!(e.players.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bench_players_do_not_contribute() {
|
||||||
|
let mut snap = SquadSnapshot {
|
||||||
|
formation: "4-4-2".into(),
|
||||||
|
players: vec![p(0, "A", "L", "N", 80), p(1, "A", "L", "N", 80)],
|
||||||
|
};
|
||||||
|
let with_both = DefaultSquadRules.evaluate(&snap);
|
||||||
|
snap.players[1].on_bench = true;
|
||||||
|
let with_bench = DefaultSquadRules.evaluate(&snap);
|
||||||
|
assert!(
|
||||||
|
with_bench.chemistry < with_both.chemistry,
|
||||||
|
"a benched team-mate must not create links: {with_bench:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(with_bench.players.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_are_capped_per_category_and_per_player() {
|
||||||
|
// Eleven identical players: club links alone would be 30 pts uncapped.
|
||||||
|
let players: Vec<_> = (0..11).map(|i| p(i, "A", "L", "N", 90)).collect();
|
||||||
|
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "4-4-2".into(),
|
||||||
|
players,
|
||||||
|
});
|
||||||
|
for pe in &e.players {
|
||||||
|
assert_eq!(pe.chemistry, 10, "per-player cap is 10: {pe:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(e.chemistry, 100);
|
||||||
|
assert_eq!(e.chemistry_max, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_chemistry_is_capped_at_the_maximum() {
|
||||||
|
// 15 starters would total 150 uncapped.
|
||||||
|
let players: Vec<_> = (0..15).map(|i| p(i, "A", "L", "N", 90)).collect();
|
||||||
|
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "x".into(),
|
||||||
|
players,
|
||||||
|
});
|
||||||
|
assert_eq!(e.chemistry, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_players_earn_no_chemistry() {
|
||||||
|
let players = vec![
|
||||||
|
p(0, "A", "L1", "N1", 80),
|
||||||
|
p(1, "B", "L2", "N2", 80),
|
||||||
|
p(2, "C", "L3", "N3", 80),
|
||||||
|
];
|
||||||
|
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "x".into(),
|
||||||
|
players,
|
||||||
|
});
|
||||||
|
assert_eq!(e.chemistry, 0);
|
||||||
|
assert_eq!(e.rating, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_evaluation_names_the_rules_that_produced_it() {
|
||||||
|
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
|
||||||
|
assert_eq!(e.rules, "openfut-default-v2");
|
||||||
|
assert_eq!(DefaultSquadRules.name(), "openfut-default-v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comparison must report disagreement rather than reconcile it.
|
||||||
|
#[test]
|
||||||
|
fn a_client_that_disagrees_is_reported_not_reconciled() {
|
||||||
|
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "x".into(),
|
||||||
|
players: vec![p(0, "A", "L", "N", 80)],
|
||||||
|
});
|
||||||
|
let claimed = ClientReportedEvaluation {
|
||||||
|
client_reported_chemistry: Some(52),
|
||||||
|
client_reported_rating: Some(server.rating),
|
||||||
|
client_reported_star_rating: None,
|
||||||
|
};
|
||||||
|
let diff = claimed.compare(&server);
|
||||||
|
assert_eq!(diff.len(), 1, "{diff:?}");
|
||||||
|
assert_eq!(diff[0].field, "chemistry");
|
||||||
|
assert_eq!(diff[0].client, 52);
|
||||||
|
assert_eq!(diff[0].server, server.chemistry);
|
||||||
|
// And the server's own value is untouched by the comparison.
|
||||||
|
assert_eq!(server.chemistry, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field the client did not send cannot disagree.
|
||||||
|
#[test]
|
||||||
|
fn absent_client_fields_are_not_treated_as_zero() {
|
||||||
|
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "x".into(),
|
||||||
|
players: vec![p(0, "A", "L", "N", 80)],
|
||||||
|
});
|
||||||
|
assert!(ClientReportedEvaluation::default()
|
||||||
|
.compare(&server)
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agreement_reports_nothing() {
|
||||||
|
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
|
||||||
|
formation: "x".into(),
|
||||||
|
players: vec![p(0, "A", "L", "N", 80)],
|
||||||
|
});
|
||||||
|
let claimed = ClientReportedEvaluation {
|
||||||
|
client_reported_chemistry: Some(server.chemistry),
|
||||||
|
client_reported_rating: Some(server.rating),
|
||||||
|
client_reported_star_rating: Some(server.star_rating),
|
||||||
|
};
|
||||||
|
assert!(claimed.compare(&server).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//! FIFA 17 development content pack + ownership seed (Commit 5).
|
||||||
|
//!
|
||||||
|
//! Proves: the dev pack is opt-in and isolated from default content; the seed
|
||||||
|
//! creates a `game_id=fifa17` profile/club and grants real Core `OwnedCard`s
|
||||||
|
//! (never FIFA wire ids); it is idempotent and leaves the default profile alone;
|
||||||
|
//! and the seeded inventory can exercise the retail `/club` filter + pagination.
|
||||||
|
|
||||||
|
use openfut_core::db::Pool;
|
||||||
|
use openfut_core::services::card_db::CardDb;
|
||||||
|
|
||||||
|
async fn pool() -> Pool {
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dev_card_db() -> CardDb {
|
||||||
|
let mut db = CardDb::load("data").expect("default cards");
|
||||||
|
db.load_game_dev("data", "fifa17").expect("dev pack");
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content isolation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_load_never_contains_dev_pack() {
|
||||||
|
// The default global loader reads only data/cards — the dev pack under
|
||||||
|
// data/games/fifa17/dev must be invisible unless explicitly requested.
|
||||||
|
let default = CardDb::load("data").expect("default cards");
|
||||||
|
let leaked: Vec<_> = default
|
||||||
|
.cards
|
||||||
|
.keys()
|
||||||
|
.filter(|k| k.starts_with("fifa17_"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
leaked.is_empty(),
|
||||||
|
"default content must not include FIFA17 dev cards: {leaked:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!default.cards.is_empty(),
|
||||||
|
"default synthetic catalogue still loads"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opt_in_load_adds_dev_pack_only() {
|
||||||
|
let default_n = CardDb::load("data").unwrap().cards.len();
|
||||||
|
let db = dev_card_db();
|
||||||
|
let dev: Vec<_> = db
|
||||||
|
.cards
|
||||||
|
.keys()
|
||||||
|
.filter(|k| k.starts_with("fifa17_"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(dev.len(), 32, "the curated dev pack is 32 definitions");
|
||||||
|
assert_eq!(
|
||||||
|
db.cards.len(),
|
||||||
|
default_n + 32,
|
||||||
|
"dev pack is additive; default content unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dev_definitions_carry_semantic_names_not_raw_ids() {
|
||||||
|
let db = dev_card_db();
|
||||||
|
for c in db.cards.values().filter(|c| c.id.starts_with("fifa17_")) {
|
||||||
|
// Semantic Core fields are names, never raw FIFA numeric entity ids.
|
||||||
|
assert!(
|
||||||
|
c.nation.parse::<i64>().is_err(),
|
||||||
|
"nation must be a name, got {:?}",
|
||||||
|
c.nation
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
c.league.parse::<i64>().is_err(),
|
||||||
|
"league must be a name: {:?}",
|
||||||
|
c.league
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
c.club.parse::<i64>().is_err(),
|
||||||
|
"club must be a name: {:?}",
|
||||||
|
c.club
|
||||||
|
);
|
||||||
|
assert!(!c.name.is_empty(), "every dev card has a player name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ownership seed ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_grants_game_scoped_inventory_with_filter_coverage() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let db = dev_card_db();
|
||||||
|
let r = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(r.game_id, "fifa17");
|
||||||
|
assert!(!r.already_seeded);
|
||||||
|
assert_eq!(r.definitions_available, 32);
|
||||||
|
assert_eq!(r.owned_total, 33, "32 defs + 1 deliberate duplicate");
|
||||||
|
assert_eq!(r.unique_definitions, 32);
|
||||||
|
assert!(r.gold_over_one_page, "gold spans >1 page (22 > 11)");
|
||||||
|
assert!(r.gold > 11, "enough gold for pagination");
|
||||||
|
assert!(r.silver >= 1 && r.bronze >= 1, "quality spread");
|
||||||
|
assert!(r.positions.contains_key("GK"), "GK present");
|
||||||
|
assert!(r.positions.contains_key("ST"), "ST present");
|
||||||
|
assert!(r.distinct_leagues >= 2, "multiple leagues");
|
||||||
|
assert!(r.distinct_nations >= 2, "multiple nations");
|
||||||
|
assert!(r.max_same_club >= 2, "a same-club group for team filters");
|
||||||
|
|
||||||
|
// The seed created ONLY a fifa17 profile — the default (fifa23) profile and
|
||||||
|
// any synthetic inventory are untouched.
|
||||||
|
let games: Vec<(String,)> = sqlx::query_as("SELECT game_id FROM profiles")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
games,
|
||||||
|
vec![("fifa17".to_string(),)],
|
||||||
|
"only the fifa17 profile exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every seeded owned card references a dev-pack definition (all renderable).
|
||||||
|
let orphans: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards o \
|
||||||
|
WHERE o.card_id LIKE 'fifa17_%' AND o.card_id NOT IN \
|
||||||
|
(SELECT card_id FROM owned_cards WHERE card_id LIKE 'fifa17_%')",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(orphans, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_is_idempotent_across_reruns() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let db = dev_card_db();
|
||||||
|
let first = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!first.already_seeded);
|
||||||
|
assert!(second.already_seeded, "second run sees existing ownership");
|
||||||
|
assert_eq!(first.owned_total, second.owned_total, "no duplicate grants");
|
||||||
|
|
||||||
|
let n: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE card_id LIKE 'fifa17_%'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 33, "row count stable after rerun");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seed_creates_exactly_one_two_copy_definition() {
|
||||||
|
let pool = pool().await;
|
||||||
|
let db = dev_card_db();
|
||||||
|
openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Exactly one definition is owned twice (distinct owned ids, same card_id):
|
||||||
|
// the identity foundation for "two copies of one card" later.
|
||||||
|
let dupes: Vec<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT card_id, COUNT(*) c FROM owned_cards WHERE card_id LIKE 'fifa17_%' \
|
||||||
|
GROUP BY card_id HAVING c > 1",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dupes.len(), 1, "exactly one duplicated definition");
|
||||||
|
assert_eq!(dupes[0].1, 2, "owned twice");
|
||||||
|
}
|
||||||
+460
-1
@@ -675,7 +675,7 @@ async fn test_draft_pick_advances_session() {
|
|||||||
assert_eq!(pick1["status"], "active");
|
assert_eq!(pick1["status"], "active");
|
||||||
assert_eq!(pick1["progress"]["filled"], 1);
|
assert_eq!(pick1["progress"]["filled"], 1);
|
||||||
assert_eq!(pick1["current_position"], "RB");
|
assert_eq!(pick1["current_position"], "RB");
|
||||||
assert!(pick1["candidates"].as_array().unwrap().len() >= 1);
|
assert!(!pick1["candidates"].as_array().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1901,3 +1901,462 @@ async fn test_trade_history_empty_initially() {
|
|||||||
assert!(json["trades"].is_array());
|
assert!(json["trades"].is_array());
|
||||||
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
|
assert_eq!(json["trades"].as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Owned-item query: filtering, deterministic order, pagination ──────────────
|
||||||
|
//
|
||||||
|
// Regression coverage for the FIFA17 "My Squad" owned-player search. Retail
|
||||||
|
// evidence (sha256-identical response bodies) proved the Python oracle applies
|
||||||
|
// ONLY league+team and ignores level/rare/position/nation/start/count, which
|
||||||
|
// re-serves page one forever and amplifies requests. Core intentionally fixes
|
||||||
|
// this: filter (AND) -> deterministic order -> paginate. Semantics only — no raw
|
||||||
|
// FIFA ids reach Core (the adapter resolves ids to the names asserted here).
|
||||||
|
|
||||||
|
/// Build an app AND keep the pool, so tests can seed a deterministic inventory.
|
||||||
|
async fn build_test_app_with_pool() -> (axum::Router, sqlx::SqlitePool) {
|
||||||
|
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");
|
||||||
|
(app, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deliberately cluttered but KNOWN owned inventory drawn from committed card
|
||||||
|
/// data. Spans qualities (gold/silver/bronze), several leagues/nations/positions
|
||||||
|
/// and includes irrelevant "junk" that must disappear under a filter. Ids are
|
||||||
|
/// `oc_NN` in listed order so the `(overall desc, owned_id asc)` order is fixed.
|
||||||
|
const CLUTTERED_FIXTURE: &[(&str, &str)] = &[
|
||||||
|
("oc_00", "card_raregold_008"), // 86 CDM Ghana / Premier League / Chelsea
|
||||||
|
("oc_01", "card_hero_004"), // 85 CDM Nigeria / Premier League / Chelsea
|
||||||
|
("oc_02", "card_raregold_010"), // 87 LB Russia / Premier League / Arsenal
|
||||||
|
("oc_03", "card_pl_001"), // 84 ST England / Premier League / Northgate
|
||||||
|
("oc_04", "card_ll_008"), // 82 ST Argentina / La Liga / Valencia Azul
|
||||||
|
("oc_05", "card_raregold_004"), // 89 LW Argentina / Primera Division / Boca
|
||||||
|
("oc_06", "card_totw_004"), // 92 LW Argentina / Primera Division / Boca
|
||||||
|
("oc_07", "card_silver_001"), // 72 ST Brazil / Brasileirao / Athletico
|
||||||
|
("oc_08", "card_silver_002"), // 70 CM Italy / Serie B / Frosinone
|
||||||
|
("oc_09", "card_bronze_001"), // 62 ST Brazil / Serie B / Santos
|
||||||
|
("oc_10", "card_bronze_002"), // 60 CM Italy / Serie C / Modena
|
||||||
|
("oc_11", "card_raregold_001"), // 88 ST Brazil / Brasileirao / Flamengo
|
||||||
|
("oc_12", "card_raregold_002"), // 86 CAM Italy / Serie A / AS Roma
|
||||||
|
("oc_13", "card_raregold_003"), // 87 CB Germany / Bundesliga / Bayer
|
||||||
|
];
|
||||||
|
|
||||||
|
async fn seed_cluttered(app: &axum::Router, pool: &sqlx::SqlitePool) {
|
||||||
|
auth(app, "ClutterClub").await;
|
||||||
|
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("club exists after auth");
|
||||||
|
// Replace the auto-granted starter pack with the deterministic fixture.
|
||||||
|
sqlx::query("DELETE FROM owned_cards WHERE club_id = ?")
|
||||||
|
.bind(&club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (oc_id, card_id) in CLUTTERED_FIXTURE {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, '2026-01-01T00:00:00Z', 'basic', NULL, 0)",
|
||||||
|
)
|
||||||
|
.bind(oc_id)
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coll_card_ids(v: &Value) -> Vec<String> {
|
||||||
|
v["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e["card"]["id"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted(mut v: Vec<String>) -> Vec<String> {
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_no_filter_returns_all() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (s, j) = json_get(&app, "/collection").await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{j}");
|
||||||
|
assert_eq!(j["total"], 14);
|
||||||
|
assert_eq!(j["returned"], 14);
|
||||||
|
assert_eq!(coll_card_ids(&j).len(), 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_quality_gold() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?quality=gold").await;
|
||||||
|
assert_eq!(j["total"], 10);
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
vec![
|
||||||
|
"card_raregold_008",
|
||||||
|
"card_hero_004",
|
||||||
|
"card_raregold_010",
|
||||||
|
"card_pl_001",
|
||||||
|
"card_ll_008",
|
||||||
|
"card_raregold_004",
|
||||||
|
"card_totw_004",
|
||||||
|
"card_raregold_001",
|
||||||
|
"card_raregold_002",
|
||||||
|
"card_raregold_003",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_position() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?position=ST").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
[
|
||||||
|
"card_pl_001",
|
||||||
|
"card_ll_008",
|
||||||
|
"card_silver_001",
|
||||||
|
"card_bronze_001",
|
||||||
|
"card_raregold_001"
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_nation() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?nation=Argentina").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
["card_ll_008", "card_raregold_004", "card_totw_004"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_league() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?league=Premier%20League").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
[
|
||||||
|
"card_raregold_008",
|
||||||
|
"card_hero_004",
|
||||||
|
"card_raregold_010",
|
||||||
|
"card_pl_001"
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_club() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?club=Chelsea").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
["card_raregold_008", "card_hero_004"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_league_and_club() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?league=Premier%20League&club=Chelsea").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
["card_raregold_008", "card_hero_004"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_league_and_position() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
|
||||||
|
assert_eq!(j["total"], 1);
|
||||||
|
assert_eq!(coll_card_ids(&j), ["card_pl_001"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_quality_and_position() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection?quality=gold&position=ST").await;
|
||||||
|
assert_eq!(
|
||||||
|
sorted(coll_card_ids(&j)),
|
||||||
|
sorted(
|
||||||
|
["card_pl_001", "card_ll_008", "card_raregold_001"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_no_results() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (s, j) = json_get(&app, "/collection?nation=Argentina&club=Chelsea").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(j["total"], 0);
|
||||||
|
assert!(coll_card_ids(&j).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_deterministic_order_overall_desc() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, j) = json_get(&app, "/collection").await;
|
||||||
|
let overalls: Vec<i64> = j["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|e| e["effective_overall"].as_i64().unwrap())
|
||||||
|
.collect();
|
||||||
|
let mut sorted_desc = overalls.clone();
|
||||||
|
sorted_desc.sort_by(|a, b| b.cmp(a));
|
||||||
|
assert_eq!(
|
||||||
|
overalls, sorted_desc,
|
||||||
|
"collection must be overall-descending"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
coll_card_ids(&j)[0],
|
||||||
|
"card_totw_004",
|
||||||
|
"highest overall (92) first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_offset_and_limit() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
// gold set ordered: totw_004, raregold_004, raregold_001, raregold_010, ...
|
||||||
|
let (_, j) = json_get(&app, "/collection?quality=gold&limit=3").await;
|
||||||
|
assert_eq!(
|
||||||
|
j["total"], 10,
|
||||||
|
"total is the filtered count, not the page size"
|
||||||
|
);
|
||||||
|
assert_eq!(j["returned"], 3);
|
||||||
|
assert_eq!(
|
||||||
|
coll_card_ids(&j),
|
||||||
|
["card_totw_004", "card_raregold_004", "card_raregold_001"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, j2) = json_get(&app, "/collection?quality=gold&offset=3&limit=3").await;
|
||||||
|
assert_eq!(j2["total"], 10);
|
||||||
|
assert_eq!(
|
||||||
|
coll_card_ids(&j2)[0],
|
||||||
|
"card_raregold_010",
|
||||||
|
"offset advances past page one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_pagination_no_repeated_first_page() {
|
||||||
|
// THE production regression: paging must advance and never re-serve page one,
|
||||||
|
// with filters retained on every page. A mutation that ignores `offset` (the
|
||||||
|
// Python bug) makes every page identical and fails here.
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
|
||||||
|
let page = |off: u32| {
|
||||||
|
let app = app.clone();
|
||||||
|
async move {
|
||||||
|
let (_, j) = json_get(
|
||||||
|
&app,
|
||||||
|
&format!("/collection?quality=gold&limit=4&start_ignored=0&offset={off}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
j
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let p0 = page(0).await;
|
||||||
|
let p1 = page(4).await;
|
||||||
|
let p2 = page(8).await;
|
||||||
|
|
||||||
|
let ids0 = coll_card_ids(&p0);
|
||||||
|
let ids1 = coll_card_ids(&p1);
|
||||||
|
let ids2 = coll_card_ids(&p2);
|
||||||
|
|
||||||
|
// sizes: 4, 4, 2 over the 10-item gold set
|
||||||
|
assert_eq!(ids0.len(), 4);
|
||||||
|
assert_eq!(ids1.len(), 4);
|
||||||
|
assert_eq!(ids2.len(), 2);
|
||||||
|
|
||||||
|
// page one is NOT repeated on later pages
|
||||||
|
assert_ne!(ids0, ids1, "offset advance must not re-serve page one");
|
||||||
|
assert_ne!(ids0, ids2);
|
||||||
|
|
||||||
|
// pairwise disjoint (no duplicates across pages)
|
||||||
|
for a in &ids0 {
|
||||||
|
assert!(
|
||||||
|
!ids1.contains(a) && !ids2.contains(a),
|
||||||
|
"pages overlap on {a}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for a in &ids1 {
|
||||||
|
assert!(!ids2.contains(a), "pages overlap on {a}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// union == the full filtered set, each page still all-gold, no dupes
|
||||||
|
let mut union: Vec<String> = ids0.iter().chain(&ids1).chain(&ids2).cloned().collect();
|
||||||
|
let count = union.len();
|
||||||
|
union.sort();
|
||||||
|
union.dedup();
|
||||||
|
assert_eq!(union.len(), count, "no duplicate items across pages");
|
||||||
|
assert_eq!(union.len(), 10, "pages cover the whole filtered set");
|
||||||
|
// filter retained across pages: every id on every page is a gold card
|
||||||
|
let (_, all_gold) = json_get(&app, "/collection?quality=gold").await;
|
||||||
|
let gold_set = sorted(coll_card_ids(&all_gold));
|
||||||
|
assert_eq!(sorted(union), gold_set);
|
||||||
|
// total constant across pages
|
||||||
|
assert_eq!(p0["total"], 10);
|
||||||
|
assert_eq!(p1["total"], 10);
|
||||||
|
assert_eq!(p2["total"], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_owned_query_parameter_order_invariance() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_cluttered(&app, &pool).await;
|
||||||
|
let (_, a) = json_get(&app, "/collection?league=Premier%20League&position=ST").await;
|
||||||
|
let (_, b) = json_get(&app, "/collection?position=ST&league=Premier%20League").await;
|
||||||
|
assert_eq!(
|
||||||
|
coll_card_ids(&a),
|
||||||
|
coll_card_ids(&b),
|
||||||
|
"HTTP param order must not change the result"
|
||||||
|
);
|
||||||
|
assert_eq!(a["total"], b["total"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(uri)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(payload.to_string()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = resp.status();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(status, serde_json::from_slice(&body).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "SquadExtUser").await;
|
||||||
|
|
||||||
|
// Owned cards from the starter pack.
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(ids.len() >= 2, "starter pack should yield >=2 owned cards");
|
||||||
|
|
||||||
|
let payload = "{\"custom\":\"[1,2,3]\",\"kit_numbers\":{}}";
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [
|
||||||
|
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||||
|
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||||
|
],
|
||||||
|
"client_reported": {
|
||||||
|
"client_reported_chemistry": 52,
|
||||||
|
"client_reported_rating": 90,
|
||||||
|
"client_reported_star_rating": 90
|
||||||
|
},
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": payload},
|
||||||
|
});
|
||||||
|
|
||||||
|
let (s, put) = json_put(&app, "/squad/replace", body.clone()).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{put}");
|
||||||
|
assert_eq!(put["slots_written"], 2);
|
||||||
|
let fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Read the canonical squad + opaque extension back: Fresh, payload verbatim.
|
||||||
|
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{ext}");
|
||||||
|
assert_eq!(ext["extension"]["state"], "fresh");
|
||||||
|
assert_eq!(ext["extension"]["payload"], payload, "opaque payload round-trips verbatim");
|
||||||
|
assert_eq!(ext["extension"]["schema_version"], 1);
|
||||||
|
assert_eq!(ext["extension"]["stored_fingerprint"], fp);
|
||||||
|
assert_eq!(ext["squad"]["formation"], "f442");
|
||||||
|
assert_eq!(ext["players"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
|
// Idempotent: an identical replacement converges to the same fingerprint.
|
||||||
|
let (s2, put2) = json_put(&app, "/squad/replace", body).await;
|
||||||
|
assert_eq!(s2, StatusCode::OK);
|
||||||
|
assert_eq!(put2["canonical_fingerprint"], fp, "identical PUT is idempotent");
|
||||||
|
|
||||||
|
// A different namespace has no stored extension: Missing, never fabricated.
|
||||||
|
let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await;
|
||||||
|
assert_eq!(other["extension"]["state"], "missing");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user