Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb54eac95 | |||
| 75b183077f | |||
| 0360135322 | |||
| bcc4f5104a | |||
| d32dc6e3ae | |||
| c8269d0df7 | |||
| ee2caa0bb0 | |||
| 66c88fb48e | |||
| 9f3c545c46 | |||
| 352ad11bc4 | |||
| 3084a46dcc | |||
| 9b2c6b82f2 | |||
| 615c5fd7a5 | |||
| 36abd4b6fb | |||
| 6acae54f80 | |||
| aecbff0de8 | |||
| 8c8a4116bf | |||
| 11a811db6a | |||
| e438b58d88 | |||
| 956bfe7a73 | |||
| 28d7490555 | |||
| c458b8cdbd | |||
| 679f147c6a | |||
| f0dbabc409 | |||
| 9e481ec072 | |||
| d97695d414 | |||
| 3d50da3589 | |||
| 26b8e9efef | |||
| 19c7b9989d | |||
| a749fba93c | |||
| bfd6de6896 | |||
| 8fa125cfd6 | |||
| 1ef2c436ab |
+1
-1
@@ -29,7 +29,7 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.8"
|
rand = { version = "0.8", features = ["small_rng"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
axum-macros = "0.4"
|
axum-macros = "0.4"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "first_match",
|
||||||
|
"title": "Debut Match",
|
||||||
|
"description": "Play your very first match.",
|
||||||
|
"icon": "⚽",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "match_veteran",
|
||||||
|
"title": "Match Veteran",
|
||||||
|
"description": "Play 10 matches.",
|
||||||
|
"icon": "🏟️",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 1500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "match_pro",
|
||||||
|
"title": "Pro Player",
|
||||||
|
"description": "Play 50 matches.",
|
||||||
|
"icon": "🎖️",
|
||||||
|
"trigger": "matches_played",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 4000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_win",
|
||||||
|
"title": "First Blood",
|
||||||
|
"description": "Win your first match.",
|
||||||
|
"icon": "🏆",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "win_10",
|
||||||
|
"title": "On Fire",
|
||||||
|
"description": "Win 10 matches.",
|
||||||
|
"icon": "🔥",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 2000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "win_50",
|
||||||
|
"title": "Unstoppable",
|
||||||
|
"description": "Win 50 matches.",
|
||||||
|
"icon": "👑",
|
||||||
|
"trigger": "matches_won",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 6000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "goalscorer_10",
|
||||||
|
"title": "Goalscorer",
|
||||||
|
"description": "Score 10 goals across all matches.",
|
||||||
|
"icon": "⚡",
|
||||||
|
"trigger": "goals_scored",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 750,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "top_scorer_50",
|
||||||
|
"title": "Top Scorer",
|
||||||
|
"description": "Score 50 goals across all matches.",
|
||||||
|
"icon": "🥅",
|
||||||
|
"trigger": "goals_scored",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 3000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_pack",
|
||||||
|
"title": "Pack Opener",
|
||||||
|
"description": "Open your first pack.",
|
||||||
|
"icon": "📦",
|
||||||
|
"trigger": "packs_opened",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pack_10",
|
||||||
|
"title": "Pack Addict",
|
||||||
|
"description": "Open 10 packs.",
|
||||||
|
"icon": "🎁",
|
||||||
|
"trigger": "packs_opened",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 2500,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collector_20",
|
||||||
|
"title": "Collector",
|
||||||
|
"description": "Own 20 cards in your collection.",
|
||||||
|
"icon": "🗂️",
|
||||||
|
"trigger": "cards_owned",
|
||||||
|
"threshold": 20,
|
||||||
|
"reward_coins": 1000,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hoarder_50",
|
||||||
|
"title": "Hoarder",
|
||||||
|
"description": "Own 50 cards in your collection.",
|
||||||
|
"icon": "💎",
|
||||||
|
"trigger": "cards_owned",
|
||||||
|
"threshold": 50,
|
||||||
|
"reward_coins": 3500,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_sbc",
|
||||||
|
"title": "Squad Builder",
|
||||||
|
"description": "Complete your first SBC challenge.",
|
||||||
|
"icon": "🧩",
|
||||||
|
"trigger": "sbcs_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 750,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_master",
|
||||||
|
"title": "SBC Master",
|
||||||
|
"description": "Complete 5 SBC challenges.",
|
||||||
|
"icon": "🔧",
|
||||||
|
"trigger": "sbcs_completed",
|
||||||
|
"threshold": 5,
|
||||||
|
"reward_coins": 4000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_5",
|
||||||
|
"title": "Rising Star",
|
||||||
|
"description": "Reach Level 5.",
|
||||||
|
"icon": "⬆",
|
||||||
|
"trigger": "level",
|
||||||
|
"threshold": 5,
|
||||||
|
"reward_coins": 2000,
|
||||||
|
"rarity": "rare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_10",
|
||||||
|
"title": "Elite Player",
|
||||||
|
"description": "Reach Level 10.",
|
||||||
|
"icon": "⭐",
|
||||||
|
"trigger": "level",
|
||||||
|
"threshold": 10,
|
||||||
|
"reward_coins": 5000,
|
||||||
|
"rarity": "epic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "first_objective",
|
||||||
|
"title": "On a Mission",
|
||||||
|
"description": "Complete your first objective.",
|
||||||
|
"icon": "✅",
|
||||||
|
"trigger": "objectives_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 500,
|
||||||
|
"rarity": "common"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "draft_debut",
|
||||||
|
"title": "Draft Veteran",
|
||||||
|
"description": "Complete a draft session.",
|
||||||
|
"icon": "🃏",
|
||||||
|
"trigger": "drafts_completed",
|
||||||
|
"threshold": 1,
|
||||||
|
"reward_coins": 1500,
|
||||||
|
"rarity": "rare"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "card_bl_001",
|
||||||
|
"name": "Klaus Berger",
|
||||||
|
"overall": 84,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Röder FC",
|
||||||
|
"pace": 82,
|
||||||
|
"shooting": 87,
|
||||||
|
"passing": 72,
|
||||||
|
"dribbling": 80,
|
||||||
|
"defending": 36,
|
||||||
|
"physical": 84,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_002",
|
||||||
|
"name": "Felix Hartmann",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Röder FC",
|
||||||
|
"pace": 74,
|
||||||
|
"shooting": 74,
|
||||||
|
"passing": 86,
|
||||||
|
"dribbling": 80,
|
||||||
|
"defending": 72,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_003",
|
||||||
|
"name": "Matteo Ricci",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Italy",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Röder FC",
|
||||||
|
"pace": 76,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 85,
|
||||||
|
"dribbling": 84,
|
||||||
|
"defending": 42,
|
||||||
|
"physical": 66,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_004",
|
||||||
|
"name": "Jan Schreiber",
|
||||||
|
"overall": 81,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Röder FC",
|
||||||
|
"pace": 74,
|
||||||
|
"shooting": 40,
|
||||||
|
"passing": 66,
|
||||||
|
"dribbling": 56,
|
||||||
|
"defending": 85,
|
||||||
|
"physical": 87,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_005",
|
||||||
|
"name": "Lucas Fonseca",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "Brazil",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Blau Stern München",
|
||||||
|
"pace": 90,
|
||||||
|
"shooting": 78,
|
||||||
|
"passing": 77,
|
||||||
|
"dribbling": 88,
|
||||||
|
"defending": 32,
|
||||||
|
"physical": 66,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_006",
|
||||||
|
"name": "Stefan Wolf",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "CDM",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Blau Stern München",
|
||||||
|
"pace": 72,
|
||||||
|
"shooting": 64,
|
||||||
|
"passing": 78,
|
||||||
|
"dribbling": 74,
|
||||||
|
"defending": 85,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_007",
|
||||||
|
"name": "Marco Bauer",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "RB",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Blau Stern München",
|
||||||
|
"pace": 82,
|
||||||
|
"shooting": 54,
|
||||||
|
"passing": 74,
|
||||||
|
"dribbling": 74,
|
||||||
|
"defending": 80,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_008",
|
||||||
|
"name": "Henri Delacroix",
|
||||||
|
"overall": 84,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Rhein United",
|
||||||
|
"pace": 86,
|
||||||
|
"shooting": 87,
|
||||||
|
"passing": 73,
|
||||||
|
"dribbling": 82,
|
||||||
|
"defending": 38,
|
||||||
|
"physical": 80,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_009",
|
||||||
|
"name": "Tobias Kohl",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Rhein United",
|
||||||
|
"pace": 56,
|
||||||
|
"shooting": 16,
|
||||||
|
"passing": 60,
|
||||||
|
"dribbling": 30,
|
||||||
|
"defending": 82,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_bl_010",
|
||||||
|
"name": "Nicolas Braun",
|
||||||
|
"overall": 81,
|
||||||
|
"position": "LB",
|
||||||
|
"nation": "Germany",
|
||||||
|
"league": "Bundesliga",
|
||||||
|
"club": "Rhein United",
|
||||||
|
"pace": 78,
|
||||||
|
"shooting": 52,
|
||||||
|
"passing": 74,
|
||||||
|
"dribbling": 72,
|
||||||
|
"defending": 81,
|
||||||
|
"physical": 78,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{"id":"card_er_001","name":"Daan van der Berg","overall":84,"position":"CAM","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":80,"shooting":78,"passing":88,"dribbling":86,"defending":45,"physical":70,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_002","name":"Luca van Dijk","overall":83,"position":"CB","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":76,"shooting":44,"passing":70,"dribbling":66,"defending":87,"physical":84,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_003","name":"Joost Bakker","overall":82,"position":"ST","nation":"Netherlands","league":"Eredivisie","club":"Feyenoord Rotterdam","pace":84,"shooting":86,"passing":68,"dribbling":80,"defending":28,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_004","name":"Sven Bosman","overall":81,"position":"CM","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":76,"shooting":68,"passing":84,"dribbling":78,"defending":66,"physical":74,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_005","name":"Tom Hendriks","overall":80,"position":"GK","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":55,"shooting":18,"passing":60,"dribbling":28,"defending":81,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_006","name":"Lars Kuipers","overall":79,"position":"RB","nation":"Netherlands","league":"Eredivisie","club":"Feyenoord Rotterdam","pace":84,"shooting":54,"passing":70,"dribbling":72,"defending":78,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_007","name":"Arjan Visser","overall":78,"position":"LW","nation":"Netherlands","league":"Eredivisie","club":"Ajax Amsterdam","pace":86,"shooting":72,"passing":70,"dribbling":82,"defending":32,"physical":62,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_er_008","name":"Pieter Smits","overall":77,"position":"CDM","nation":"Netherlands","league":"Eredivisie","club":"PSV Eindhoven","pace":72,"shooting":56,"passing":72,"dribbling":68,"defending":82,"physical":78,"rarity":"gold","image_path":null}
|
||||||
|
]
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "card_ll_001",
|
||||||
|
"name": "Carlos Mendez",
|
||||||
|
"overall": 85,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Atletico Fuego",
|
||||||
|
"pace": 86,
|
||||||
|
"shooting": 89,
|
||||||
|
"passing": 72,
|
||||||
|
"dribbling": 84,
|
||||||
|
"defending": 36,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_002",
|
||||||
|
"name": "Pablo Serrano",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Atletico Fuego",
|
||||||
|
"pace": 78,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 86,
|
||||||
|
"dribbling": 86,
|
||||||
|
"defending": 46,
|
||||||
|
"physical": 70,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_003",
|
||||||
|
"name": "Rodrigo Vidal",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Atletico Fuego",
|
||||||
|
"pace": 74,
|
||||||
|
"shooting": 70,
|
||||||
|
"passing": 84,
|
||||||
|
"dribbling": 80,
|
||||||
|
"defending": 72,
|
||||||
|
"physical": 74,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_004",
|
||||||
|
"name": "Andrés Torres",
|
||||||
|
"overall": 81,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Atletico Fuego",
|
||||||
|
"pace": 73,
|
||||||
|
"shooting": 38,
|
||||||
|
"passing": 68,
|
||||||
|
"dribbling": 58,
|
||||||
|
"defending": 84,
|
||||||
|
"physical": 86,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_005",
|
||||||
|
"name": "Gabriel Costa",
|
||||||
|
"overall": 84,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "Brazil",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Real Viento",
|
||||||
|
"pace": 92,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 78,
|
||||||
|
"dribbling": 90,
|
||||||
|
"defending": 32,
|
||||||
|
"physical": 64,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_006",
|
||||||
|
"name": "Javier Ruiz",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "CDM",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Real Viento",
|
||||||
|
"pace": 72,
|
||||||
|
"shooting": 62,
|
||||||
|
"passing": 80,
|
||||||
|
"dribbling": 76,
|
||||||
|
"defending": 86,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_007",
|
||||||
|
"name": "Miguel Ángel Prieto",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "RB",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Real Viento",
|
||||||
|
"pace": 80,
|
||||||
|
"shooting": 52,
|
||||||
|
"passing": 76,
|
||||||
|
"dribbling": 74,
|
||||||
|
"defending": 80,
|
||||||
|
"physical": 74,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_008",
|
||||||
|
"name": "Diego Reyes",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Argentina",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Valencia Azul",
|
||||||
|
"pace": 85,
|
||||||
|
"shooting": 84,
|
||||||
|
"passing": 70,
|
||||||
|
"dribbling": 82,
|
||||||
|
"defending": 36,
|
||||||
|
"physical": 78,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_009",
|
||||||
|
"name": "Marc Gutiérrez",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Valencia Azul",
|
||||||
|
"pace": 54,
|
||||||
|
"shooting": 14,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 28,
|
||||||
|
"defending": 81,
|
||||||
|
"physical": 80,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_010",
|
||||||
|
"name": "Emilio Navarro",
|
||||||
|
"overall": 81,
|
||||||
|
"position": "LB",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Valencia Azul",
|
||||||
|
"pace": 79,
|
||||||
|
"shooting": 52,
|
||||||
|
"passing": 74,
|
||||||
|
"dribbling": 72,
|
||||||
|
"defending": 82,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_011",
|
||||||
|
"name": "Thiago Drummond",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Brazil",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Atletico Fuego",
|
||||||
|
"pace": 76,
|
||||||
|
"shooting": 38,
|
||||||
|
"passing": 68,
|
||||||
|
"dribbling": 62,
|
||||||
|
"defending": 86,
|
||||||
|
"physical": 88,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_ll_012",
|
||||||
|
"name": "Fran Molina",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "RW",
|
||||||
|
"nation": "Spain",
|
||||||
|
"league": "La Liga",
|
||||||
|
"club": "Real Viento",
|
||||||
|
"pace": 87,
|
||||||
|
"shooting": 76,
|
||||||
|
"passing": 76,
|
||||||
|
"dribbling": 85,
|
||||||
|
"defending": 34,
|
||||||
|
"physical": 66,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{"id":"card_ln_001","name":"Diogo Ferreira","overall":85,"position":"ST","nation":"Portugal","league":"Primeira Liga","club":"Lisboa United","pace":82,"shooting":88,"passing":74,"dribbling":84,"defending":30,"physical":82,"rarity":"raregold","image_path":null},
|
||||||
|
{"id":"card_ln_002","name":"Rúben Costa","overall":83,"position":"CAM","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":78,"shooting":78,"passing":88,"dribbling":84,"defending":46,"physical":70,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_003","name":"André Sousa","overall":82,"position":"CB","nation":"Portugal","league":"Primeira Liga","club":"Sporting Algarve","pace":72,"shooting":44,"passing":68,"dribbling":62,"defending":86,"physical":82,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_004","name":"Tiago Fonseca","overall":81,"position":"CDM","nation":"Portugal","league":"Primeira Liga","club":"Braga Norte","pace":70,"shooting":56,"passing":74,"dribbling":70,"defending":84,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_005","name":"João Mendes","overall":80,"position":"LB","nation":"Portugal","league":"Primeira Liga","club":"Lisboa United","pace":82,"shooting":54,"passing":72,"dribbling":74,"defending":80,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_006","name":"Nuno Pinto","overall":79,"position":"GK","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":52,"shooting":16,"passing":58,"dribbling":26,"defending":80,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_007","name":"Gonçalo Lopes","overall":78,"position":"RW","nation":"Portugal","league":"Primeira Liga","club":"Sporting Algarve","pace":84,"shooting":72,"passing":68,"dribbling":80,"defending":32,"physical":64,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_008","name":"Carlos Rodrigues","overall":77,"position":"CM","nation":"Portugal","league":"Primeira Liga","club":"Braga Norte","pace":74,"shooting":66,"passing":78,"dribbling":74,"defending":68,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_009","name":"Mamadou Diallo","overall":80,"position":"ST","nation":"Senegal","league":"Primeira Liga","club":"Lisboa United","pace":90,"shooting":80,"passing":62,"dribbling":76,"defending":26,"physical":84,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_ln_010","name":"Bruno Fernandes Júnior","overall":76,"position":"LW","nation":"Portugal","league":"Primeira Liga","club":"Porto Sul","pace":82,"shooting":70,"passing":70,"dribbling":78,"defending":30,"physical":62,"rarity":"gold","image_path":null}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{"id":"card_l1_001","name":"Antoine Mercier","overall":88,"position":"ST","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":85,"shooting":91,"passing":75,"dribbling":86,"defending":30,"physical":83,"rarity":"raregold","image_path":null},
|
||||||
|
{"id":"card_l1_002","name":"Kylian Dupont","overall":86,"position":"CAM","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":92,"shooting":80,"passing":84,"dribbling":90,"defending":38,"physical":70,"rarity":"raregold","image_path":null},
|
||||||
|
{"id":"card_l1_003","name":"Théo Girard","overall":84,"position":"CM","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":76,"shooting":72,"passing":86,"dribbling":82,"defending":65,"physical":74,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_004","name":"Hugo Blanc","overall":83,"position":"GK","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":54,"shooting":18,"passing":60,"dribbling":28,"defending":84,"physical":82,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_005","name":"Alexis Bernard","overall":82,"position":"CB","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":74,"shooting":42,"passing":66,"dribbling":62,"defending":86,"physical":84,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_006","name":"Lucas Petit","overall":81,"position":"LB","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":84,"shooting":56,"passing":76,"dribbling":74,"defending":80,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_007","name":"Mathieu Renard","overall":80,"position":"CDM","nation":"France","league":"Ligue 1","club":"Bordeaux Atlantique","pace":72,"shooting":58,"passing":74,"dribbling":70,"defending":83,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_008","name":"Romain Favre","overall":79,"position":"RW","nation":"France","league":"Ligue 1","club":"Paris Olympique","pace":86,"shooting":74,"passing":70,"dribbling":84,"defending":36,"physical":64,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_009","name":"Nicolas Aubert","overall":78,"position":"RB","nation":"France","league":"Ligue 1","club":"Marseille Olympia","pace":82,"shooting":52,"passing":70,"dribbling":72,"defending":80,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_010","name":"Samuel Perrin","overall":77,"position":"LW","nation":"France","league":"Ligue 1","club":"Lyon Premier","pace":84,"shooting":70,"passing":68,"dribbling":80,"defending":34,"physical":62,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_011","name":"Ibrahim Coulibaly","overall":81,"position":"ST","nation":"Ivory Coast","league":"Ligue 1","club":"Bordeaux Atlantique","pace":88,"shooting":82,"passing":65,"dribbling":80,"defending":28,"physical":82,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_l1_012","name":"Pedro Alves","overall":80,"position":"CAM","nation":"Portugal","league":"Ligue 1","club":"Paris Olympique","pace":80,"shooting":76,"passing":82,"dribbling":84,"defending":44,"physical":68,"rarity":"gold","image_path":null}
|
||||||
|
]
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "card_pl_001",
|
||||||
|
"name": "James Caldwell",
|
||||||
|
"overall": 84,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Northgate United",
|
||||||
|
"pace": 84,
|
||||||
|
"shooting": 87,
|
||||||
|
"passing": 70,
|
||||||
|
"dribbling": 80,
|
||||||
|
"defending": 36,
|
||||||
|
"physical": 82,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_002",
|
||||||
|
"name": "Danny Walsh",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "CM",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Northgate United",
|
||||||
|
"pace": 74,
|
||||||
|
"shooting": 72,
|
||||||
|
"passing": 84,
|
||||||
|
"dribbling": 79,
|
||||||
|
"defending": 70,
|
||||||
|
"physical": 74,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_003",
|
||||||
|
"name": "Reece Okafor",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "LW",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Northgate United",
|
||||||
|
"pace": 91,
|
||||||
|
"shooting": 78,
|
||||||
|
"passing": 75,
|
||||||
|
"dribbling": 88,
|
||||||
|
"defending": 34,
|
||||||
|
"physical": 68,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_004",
|
||||||
|
"name": "Tom Briggs",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Northgate United",
|
||||||
|
"pace": 72,
|
||||||
|
"shooting": 36,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 52,
|
||||||
|
"defending": 83,
|
||||||
|
"physical": 86,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_005",
|
||||||
|
"name": "Pierre-Marc Dubois",
|
||||||
|
"overall": 84,
|
||||||
|
"position": "CAM",
|
||||||
|
"nation": "France",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Southfield City",
|
||||||
|
"pace": 79,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 87,
|
||||||
|
"dribbling": 85,
|
||||||
|
"defending": 44,
|
||||||
|
"physical": 70,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_006",
|
||||||
|
"name": "Kwame Asante",
|
||||||
|
"overall": 85,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Ghana",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Southfield City",
|
||||||
|
"pace": 88,
|
||||||
|
"shooting": 88,
|
||||||
|
"passing": 70,
|
||||||
|
"dribbling": 84,
|
||||||
|
"defending": 38,
|
||||||
|
"physical": 86,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_007",
|
||||||
|
"name": "Liam Foster",
|
||||||
|
"overall": 81,
|
||||||
|
"position": "CDM",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Southfield City",
|
||||||
|
"pace": 74,
|
||||||
|
"shooting": 62,
|
||||||
|
"passing": 78,
|
||||||
|
"dribbling": 74,
|
||||||
|
"defending": 84,
|
||||||
|
"physical": 80,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_008",
|
||||||
|
"name": "Sergio Rojas",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "RW",
|
||||||
|
"nation": "Argentina",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Westbrook FC",
|
||||||
|
"pace": 89,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 78,
|
||||||
|
"dribbling": 90,
|
||||||
|
"defending": 30,
|
||||||
|
"physical": 62,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_009",
|
||||||
|
"name": "Ben Archer",
|
||||||
|
"overall": 79,
|
||||||
|
"position": "GK",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Westbrook FC",
|
||||||
|
"pace": 52,
|
||||||
|
"shooting": 14,
|
||||||
|
"passing": 62,
|
||||||
|
"dribbling": 28,
|
||||||
|
"defending": 80,
|
||||||
|
"physical": 80,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_010",
|
||||||
|
"name": "Nathan Cross",
|
||||||
|
"overall": 80,
|
||||||
|
"position": "RB",
|
||||||
|
"nation": "England",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Westbrook FC",
|
||||||
|
"pace": 81,
|
||||||
|
"shooting": 50,
|
||||||
|
"passing": 74,
|
||||||
|
"dribbling": 72,
|
||||||
|
"defending": 80,
|
||||||
|
"physical": 76,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_011",
|
||||||
|
"name": "Marco Silva Jr.",
|
||||||
|
"overall": 83,
|
||||||
|
"position": "CB",
|
||||||
|
"nation": "Brazil",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Westbrook FC",
|
||||||
|
"pace": 77,
|
||||||
|
"shooting": 40,
|
||||||
|
"passing": 66,
|
||||||
|
"dribbling": 60,
|
||||||
|
"defending": 86,
|
||||||
|
"physical": 88,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card_pl_012",
|
||||||
|
"name": "Erik Strand",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "LB",
|
||||||
|
"nation": "Norway",
|
||||||
|
"league": "Premier League",
|
||||||
|
"club": "Northgate United",
|
||||||
|
"pace": 80,
|
||||||
|
"shooting": 54,
|
||||||
|
"passing": 76,
|
||||||
|
"dribbling": 74,
|
||||||
|
"defending": 82,
|
||||||
|
"physical": 78,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": null
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{"id":"card_sa_001","name":"Lorenzo Ricci","overall":87,"position":"ST","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":83,"shooting":90,"passing":72,"dribbling":84,"defending":32,"physical":85,"rarity":"raregold","image_path":null},
|
||||||
|
{"id":"card_sa_002","name":"Marco Ferretti","overall":85,"position":"CAM","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":80,"shooting":82,"passing":88,"dribbling":87,"defending":40,"physical":72,"rarity":"raregold","image_path":null},
|
||||||
|
{"id":"card_sa_003","name":"Davide Conti","overall":83,"position":"CB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":72,"shooting":45,"passing":68,"dribbling":65,"defending":87,"physical":84,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_004","name":"Emanuele Vitali","overall":82,"position":"CM","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":74,"shooting":70,"passing":84,"dribbling":80,"defending":68,"physical":76,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_005","name":"Filippo Marchetti","overall":84,"position":"GK","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":55,"shooting":20,"passing":58,"dribbling":30,"defending":85,"physical":82,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_006","name":"Gianluca Russo","overall":81,"position":"LB","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":82,"shooting":52,"passing":74,"dribbling":76,"defending":80,"physical":74,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_007","name":"Alessandro Greco","overall":80,"position":"RB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":84,"shooting":55,"passing":72,"dribbling":74,"defending":78,"physical":72,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_008","name":"Federico Palermo","overall":82,"position":"CDM","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":70,"shooting":58,"passing":76,"dribbling":72,"defending":84,"physical":82,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_009","name":"Simone Bianchi","overall":79,"position":"RW","nation":"Italy","league":"Serie A","club":"Roma Capitale FC","pace":88,"shooting":76,"passing":72,"dribbling":82,"defending":38,"physical":65,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_010","name":"Roberto Neri","overall":78,"position":"LW","nation":"Italy","league":"Serie A","club":"Milano Azzurri","pace":86,"shooting":74,"passing":70,"dribbling":80,"defending":35,"physical":62,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_011","name":"Matteo De Luca","overall":77,"position":"CB","nation":"Italy","league":"Serie A","club":"Napoli Sud FC","pace":68,"shooting":40,"passing":62,"dribbling":60,"defending":80,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_012","name":"Claudio Esposito","overall":76,"position":"ST","nation":"Italy","league":"Serie A","club":"Torino Granata","pace":80,"shooting":78,"passing":62,"dribbling":74,"defending":28,"physical":76,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_013","name":"Nicolás Peralta","overall":83,"position":"ST","nation":"Argentina","league":"Serie A","club":"Roma Capitale FC","pace":82,"shooting":86,"passing":70,"dribbling":82,"defending":30,"physical":80,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_014","name":"Henrique Sousa","overall":80,"position":"CAM","nation":"Brazil","league":"Serie A","club":"Milano Azzurri","pace":82,"shooting":76,"passing":82,"dribbling":86,"defending":42,"physical":68,"rarity":"gold","image_path":null},
|
||||||
|
{"id":"card_sa_015","name":"Sven Holm","overall":75,"position":"CM","nation":"Sweden","league":"Serie A","club":"Napoli Sud FC","pace":72,"shooting":65,"passing":78,"dribbling":74,"defending":70,"physical":72,"rarity":"gold","image_path":null}
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "basic",
|
||||||
|
"name": "Basic",
|
||||||
|
"description": "Small boosts across all attributes",
|
||||||
|
"boosts": { "pace": 3, "shooting": 3, "passing": 3, "dribbling": 3, "defending": 3, "physical": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "shadow",
|
||||||
|
"name": "Shadow",
|
||||||
|
"description": "Maximises pace and defending — ideal for full-backs",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anchor",
|
||||||
|
"name": "Anchor",
|
||||||
|
"description": "Boosts defending and physicality — built for centre-backs",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 10, "physical": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hunter",
|
||||||
|
"name": "Hunter",
|
||||||
|
"description": "Pace and shooting — the striker's best friend",
|
||||||
|
"boosts": { "pace": 10, "shooting": 10, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "catalyst",
|
||||||
|
"name": "Catalyst",
|
||||||
|
"description": "Pace and passing for box-to-box and wide midfielders",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "engine",
|
||||||
|
"name": "Engine",
|
||||||
|
"description": "Pace, passing and dribbling for creative midfielders",
|
||||||
|
"boosts": { "pace": 10, "shooting": 0, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hawk",
|
||||||
|
"name": "Hawk",
|
||||||
|
"description": "Balanced pace, shooting and physicality",
|
||||||
|
"boosts": { "pace": 5, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 0, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marksman",
|
||||||
|
"name": "Marksman",
|
||||||
|
"description": "Shooting, dribbling and physicality for physical strikers",
|
||||||
|
"boosts": { "pace": 0, "shooting": 10, "passing": 0, "dribbling": 5, "defending": 0, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deadeye",
|
||||||
|
"name": "Deadeye",
|
||||||
|
"description": "Shooting and passing — classic number 10 style",
|
||||||
|
"boosts": { "pace": 0, "shooting": 10, "passing": 10, "dribbling": 0, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "artist",
|
||||||
|
"name": "Artist",
|
||||||
|
"description": "Passing and dribbling for technical playmakers",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 10, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sniper",
|
||||||
|
"name": "Sniper",
|
||||||
|
"description": "Shooting and dribbling for agile forwards",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "finisher",
|
||||||
|
"name": "Finisher",
|
||||||
|
"description": "Shooting and dribbling — precision over power",
|
||||||
|
"boosts": { "pace": 0, "shooting": 8, "passing": 0, "dribbling": 7, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "guardian",
|
||||||
|
"name": "Guardian",
|
||||||
|
"description": "Dribbling and defending for defensive midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 5, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backbone",
|
||||||
|
"name": "Backbone",
|
||||||
|
"description": "Passing, defending and physicality for the defensive shield",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 5, "dribbling": 0, "defending": 5, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sentinel",
|
||||||
|
"name": "Sentinel",
|
||||||
|
"description": "Defending and physicality for no-nonsense defenders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 5, "physical": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "powerhouse",
|
||||||
|
"name": "Powerhouse",
|
||||||
|
"description": "Passing and defending for dominant central midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 0, "passing": 10, "dribbling": 0, "defending": 10, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "maestro",
|
||||||
|
"name": "Maestro",
|
||||||
|
"description": "Shooting, passing and dribbling — the complete midfielder",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 5, "dribbling": 5, "defending": 0, "physical": 0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gladiator",
|
||||||
|
"name": "Gladiator",
|
||||||
|
"description": "Shooting and defending for all-action midfielders",
|
||||||
|
"boosts": { "pace": 0, "shooting": 5, "passing": 0, "dribbling": 0, "defending": 5, "physical": 0 }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "spring_festival",
|
||||||
|
"name": "Spring Festival",
|
||||||
|
"description": "Celebrate the season! Special Hero players appear in the market. Activate manually to begin.",
|
||||||
|
"event_type": "seasonal",
|
||||||
|
"starts_at": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"is_manual": true,
|
||||||
|
"effects": {
|
||||||
|
"pack_weight_boosts": [],
|
||||||
|
"bonus_market_cards": [
|
||||||
|
"card_hero_001",
|
||||||
|
"card_hero_002",
|
||||||
|
"card_hero_003"
|
||||||
|
],
|
||||||
|
"unlocks_sbc_ids": [],
|
||||||
|
"unlocks_objective_ids": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "icon_weekend",
|
||||||
|
"name": "Icon Weekend",
|
||||||
|
"description": "Legendary Icons are available on the market. Activate manually to begin.",
|
||||||
|
"event_type": "special",
|
||||||
|
"starts_at": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"is_manual": true,
|
||||||
|
"effects": {
|
||||||
|
"pack_weight_boosts": [
|
||||||
|
{ "rarity": "icon", "multiplier": 5.0 }
|
||||||
|
],
|
||||||
|
"bonus_market_cards": [
|
||||||
|
"card_icon_001",
|
||||||
|
"card_icon_002",
|
||||||
|
"card_icon_003",
|
||||||
|
"card_icon_004",
|
||||||
|
"card_icon_005"
|
||||||
|
],
|
||||||
|
"unlocks_sbc_ids": [],
|
||||||
|
"unlocks_objective_ids": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "totw_week1",
|
||||||
|
"name": "Team of the Week 1",
|
||||||
|
"description": "This week's TOTW is live! TOTW players appear in the transfer market at a premium and pack weights are boosted.",
|
||||||
|
"event_type": "totw",
|
||||||
|
"starts_at": "2024-01-01T00:00:00Z",
|
||||||
|
"expires_at": "2030-12-31T23:59:59Z",
|
||||||
|
"is_manual": false,
|
||||||
|
"effects": {
|
||||||
|
"pack_weight_boosts": [
|
||||||
|
{ "rarity": "totw", "multiplier": 3.0 }
|
||||||
|
],
|
||||||
|
"bonus_market_cards": [
|
||||||
|
"card_totw_001",
|
||||||
|
"card_totw_002",
|
||||||
|
"card_totw_003",
|
||||||
|
"card_totw_004",
|
||||||
|
"card_totw_005"
|
||||||
|
],
|
||||||
|
"unlocks_sbc_ids": [],
|
||||||
|
"unlocks_objective_ids": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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,202 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sbc_serie_a_rising",
|
||||||
|
"name": "Serie A Rising Stars",
|
||||||
|
"description": "Build a squad using 7 or more Serie A players (average overall 74+).",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 74,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": ["Serie A"],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": 7,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_ligue1_galacticos",
|
||||||
|
"name": "Ligue 1 Galacticos",
|
||||||
|
"description": "Field a Ligue 1 XI with at least one player rated 84 or higher.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 72,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": ["Ligue 1"],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": 11,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 3500, "xp": 400, "pack_id": "rare_gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_iberian_derby",
|
||||||
|
"name": "Iberian Derby",
|
||||||
|
"description": "An all-Iberian battle — include at least 3 Spanish and 3 Portuguese players.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 70,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": [],
|
||||||
|
"required_nations": ["Spain", "Portugal"],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": null,
|
||||||
|
"min_players_from_same_nation": 3,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 2500, "xp": 300, "pack_id": "silver_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_dutch_masters",
|
||||||
|
"name": "Dutch Masters",
|
||||||
|
"description": "8 or more Eredivisie players in one squad (average overall 70+).",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 70,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": ["Eredivisie"],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": 8,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_elite_strikers",
|
||||||
|
"name": "Elite Strikers",
|
||||||
|
"description": "Submit any 3 players rated 82+ — the club receives a bonus pack.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 3,
|
||||||
|
"min_overall": 82,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": [],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": null,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 1000, "xp": 200, "pack_id": "rare_gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_world_tour",
|
||||||
|
"name": "World Tour",
|
||||||
|
"description": "11 players from at least 8 different nations — celebrate global football.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 65,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": [],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": null,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 1500, "xp": 200, "pack_id": "silver_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_bundesliga_clash",
|
||||||
|
"name": "Bundesliga Clash",
|
||||||
|
"description": "7+ Bundesliga players (average overall 72+) for a gold pack reward.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 72,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": ["Bundesliga"],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": 7,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 2000, "xp": 250, "pack_id": "gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_premier_power",
|
||||||
|
"name": "Premier Power",
|
||||||
|
"description": "7+ Premier League players (average overall 75+) for a rare gold pack.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 75,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": ["Premier League"],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": 7,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 3000, "xp": 350, "pack_id": "rare_gold_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_south_american_fire",
|
||||||
|
"name": "South American Fire",
|
||||||
|
"description": "At least 4 Brazilian and 4 Argentinian players in a squad.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 11,
|
||||||
|
"min_overall": 65,
|
||||||
|
"max_overall": null,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": [],
|
||||||
|
"required_nations": ["Brazil", "Argentina"],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": null,
|
||||||
|
"min_players_from_same_nation": 4,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 3000, "xp": 400, "pack_id": "silver_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sbc_bronze_to_silver",
|
||||||
|
"name": "Bronze to Silver",
|
||||||
|
"description": "Submit 7 bronze players (overall 55–64) to get a silver pack.",
|
||||||
|
"requirements": {
|
||||||
|
"squad_size": 7,
|
||||||
|
"min_overall": 55,
|
||||||
|
"max_overall": 64,
|
||||||
|
"min_chemistry": null,
|
||||||
|
"required_leagues": [],
|
||||||
|
"required_nations": [],
|
||||||
|
"required_clubs": [],
|
||||||
|
"min_players_from_same_league": null,
|
||||||
|
"min_players_from_same_nation": null,
|
||||||
|
"min_players_from_same_club": null
|
||||||
|
},
|
||||||
|
"reward": { "coins": 0, "xp": 150, "pack_id": "silver_pack" },
|
||||||
|
"expires_at": null,
|
||||||
|
"repeatable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Manual override table for event activation/deactivation.
|
||||||
|
-- is_active_override: NULL = use date range, 1 = force active, 0 = force inactive
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
is_active_override INTEGER,
|
||||||
|
activated_at TEXT,
|
||||||
|
deactivated_at TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Stateful FUT-style draft sessions.
|
||||||
|
-- Each session tracks position order, per-position candidates, and picks made.
|
||||||
|
CREATE TABLE IF NOT EXISTS draft_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
difficulty TEXT NOT NULL DEFAULT 'professional',
|
||||||
|
pick_order TEXT NOT NULL, -- JSON array of position strings, e.g. ["GK","RB",...]
|
||||||
|
picks TEXT NOT NULL DEFAULT '[]', -- JSON array of picked card_ids (len tracks progress)
|
||||||
|
current_candidates TEXT, -- JSON array of card_ids offered for the current pick
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active | completed | abandoned
|
||||||
|
reward_coins INTEGER DEFAULT 0,
|
||||||
|
reward_pack_id TEXT,
|
||||||
|
squad_rating INTEGER DEFAULT 0,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Division / season tracking (one row per profile, reset each season)
|
||||||
|
CREATE TABLE IF NOT EXISTS seasons (
|
||||||
|
profile_id TEXT PRIMARY KEY,
|
||||||
|
division INTEGER NOT NULL DEFAULT 5,
|
||||||
|
season_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
season_points INTEGER NOT NULL DEFAULT 0,
|
||||||
|
matches_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draws INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pack open history: store which card_ids came out of each open
|
||||||
|
ALTER TABLE packs ADD COLUMN opened_cards TEXT;
|
||||||
|
ALTER TABLE packs ADD COLUMN opened_at TEXT;
|
||||||
|
|
||||||
|
-- Club cosmetic customization
|
||||||
|
ALTER TABLE clubs ADD COLUMN manager_name TEXT DEFAULT 'Player Manager';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Phase 10: card upgrade system
|
||||||
|
-- chemistry_style: applied cosmetic/stat modifier (default "basic")
|
||||||
|
-- position_override: player moved to a different position from their card default
|
||||||
|
-- training_bonus: extra OVR points from training cards (capped at +3)
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN chemistry_style TEXT NOT NULL DEFAULT 'basic';
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN position_override TEXT;
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN training_bonus INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Phase 11: FUT Champions / Weekend League sessions
|
||||||
|
CREATE TABLE IF NOT EXISTS fut_champs_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
week_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
matches_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draws INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
reward_tier TEXT,
|
||||||
|
reward_coins INTEGER,
|
||||||
|
reward_pack_id TEXT,
|
||||||
|
reward_claimed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Division Rivals: track when each profile last claimed their weekly reward
|
||||||
|
ALTER TABLE seasons ADD COLUMN rivals_week_claimed INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE seasons ADD COLUMN rivals_total_points INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Persistent event-driven notifications (level-ups, loan expiry, season end, etc.)
|
||||||
|
-- Dynamic state notifications (expiring loans, unclaimed objectives) remain in code.
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications (created_at DESC);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS player_achievements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
achievement_id TEXT NOT NULL UNIQUE,
|
||||||
|
unlocked_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_achievements_id ON player_achievements (achievement_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Season history: one row per completed season
|
||||||
|
CREATE TABLE IF NOT EXISTS season_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
season_number INTEGER NOT NULL,
|
||||||
|
division INTEGER NOT NULL,
|
||||||
|
season_points INTEGER NOT NULL,
|
||||||
|
wins INTEGER NOT NULL,
|
||||||
|
draws INTEGER NOT NULL,
|
||||||
|
losses INTEGER NOT NULL,
|
||||||
|
result TEXT NOT NULL, -- 'promoted' | 'maintained' | 'relegated'
|
||||||
|
new_division INTEGER NOT NULL,
|
||||||
|
coins_awarded INTEGER NOT NULL,
|
||||||
|
pack_awarded TEXT, -- nullable
|
||||||
|
ended_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_season_history_profile ON season_history (profile_id, season_number DESC);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS daily_checkins (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
club_id TEXT NOT NULL,
|
||||||
|
streak_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
coins_awarded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pack_granted TEXT, -- nullable pack definition id
|
||||||
|
checked_in_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_checkins_profile ON daily_checkins (profile_id, checked_in_at DESC);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- One row per buy or sell event, for trade history display
|
||||||
|
CREATE TABLE IF NOT EXISTS market_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
club_id TEXT NOT NULL,
|
||||||
|
card_id TEXT NOT NULL, -- definition card id
|
||||||
|
card_name TEXT NOT NULL,
|
||||||
|
card_overall INTEGER NOT NULL,
|
||||||
|
trade_type TEXT NOT NULL, -- 'buy' | 'sell'
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
traded_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_history_club ON market_history (club_id, traded_at DESC);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Distinguish NPC-generated listings from player-posted ones so that the
|
||||||
|
-- periodic NPC refresh does not accidentally wipe player listings.
|
||||||
|
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Track when the player last claimed their rivals weekly reward to enforce a
|
||||||
|
-- 24-hour cooldown between claims.
|
||||||
|
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Multi-game support. Every profile (and therefore all of its downstream state,
|
||||||
|
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
|
||||||
|
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
|
||||||
|
--
|
||||||
|
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
|
||||||
|
-- and the integration tests send no game header, so they keep operating on the same
|
||||||
|
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
|
||||||
|
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
|
||||||
|
--
|
||||||
|
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
|
||||||
|
-- all other tables reference a profile (directly via profile_id or via
|
||||||
|
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
|
||||||
|
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Generic provenance/rerun-identity token for a transactionally imported profile.
|
||||||
|
--
|
||||||
|
-- Set by the generic profile-import path (services::import). A NULL value means
|
||||||
|
-- the profile was created by normal gameplay / dev seeding, not an import, and
|
||||||
|
-- MUST NOT be silently clobbered by an import targeting the same game. A
|
||||||
|
-- matching token on a re-run is an idempotent no-op; a differing token against
|
||||||
|
-- an already-imported game fails until an explicit update mode exists.
|
||||||
|
--
|
||||||
|
-- Core never interprets the token's structure; the importer adapter chooses it.
|
||||||
|
ALTER TABLE profiles ADD COLUMN import_fingerprint TEXT;
|
||||||
+192
-7
@@ -2,20 +2,24 @@ use crate::middleware::{limit_concurrency, MakeRequestUuid};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Pool,
|
db::Pool,
|
||||||
|
models::achievement::AchievementDefinition,
|
||||||
|
models::chemistry_style::{load_chemistry_styles, ChemistryStyle},
|
||||||
|
models::event::EventDefinition,
|
||||||
models::objective::{ObjectiveDefinition, ObjectiveType},
|
models::objective::{ObjectiveDefinition, ObjectiveType},
|
||||||
models::pack::PackDefinition,
|
models::pack::PackDefinition,
|
||||||
models::sbc::SbcDefinition,
|
models::sbc::SbcDefinition,
|
||||||
routes,
|
routes,
|
||||||
services::{
|
services::{
|
||||||
card_db::CardDb, market, objective::load_objective_definitions,
|
achievement::load_achievement_definitions, card_db::CardDb, event::load_event_definitions,
|
||||||
pack::load_pack_definitions, sbc::load_sbc_definitions,
|
market, objective::load_objective_definitions, pack::load_pack_definitions,
|
||||||
|
sbc::load_sbc_definitions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -32,20 +36,60 @@ pub struct AppState {
|
|||||||
pub pack_defs: Arc<Vec<PackDefinition>>,
|
pub pack_defs: Arc<Vec<PackDefinition>>,
|
||||||
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
pub obj_defs: Arc<Vec<ObjectiveDefinition>>,
|
||||||
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
pub sbc_defs: Arc<Vec<SbcDefinition>>,
|
||||||
|
pub event_defs: Arc<Vec<EventDefinition>>,
|
||||||
|
pub chem_styles: Arc<Vec<ChemistryStyle>>,
|
||||||
|
pub achievement_defs: Arc<Vec<AchievementDefinition>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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)?;
|
||||||
|
}
|
||||||
|
for pack in &cfg.content_packs {
|
||||||
|
card_db.load_pack(pack)?;
|
||||||
|
}
|
||||||
|
let card_db = Arc::new(card_db);
|
||||||
|
|
||||||
|
// Content preflight: every owned card MUST reference a loaded CardDefinition.
|
||||||
|
// A real profile with owned players but missing definitions fails LOUDLY here
|
||||||
|
// rather than silently serving an empty /collection. Empty owned_cards (fresh
|
||||||
|
// DB, tests) passes. A SINGLE missing definition is caught, not only the
|
||||||
|
// zero-loaded case.
|
||||||
|
{
|
||||||
|
let referenced: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
let missing: Vec<String> = referenced
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| card_db.get(id).is_none())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
let sample: Vec<&String> = missing.iter().take(5).collect();
|
||||||
|
anyhow::bail!(
|
||||||
|
"content preflight failed: {} owned card(s) reference CardDefinitionId(s) not loaded (e.g. {:?}). Load the production content pack via OPENFUT_CONTENT_PACKS.",
|
||||||
|
missing.len(),
|
||||||
|
sample
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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)?);
|
||||||
|
let event_defs = Arc::new(load_event_definitions(&cfg.data_dir)?);
|
||||||
|
let chem_styles = Arc::new(load_chemistry_styles(&cfg.data_dir)?);
|
||||||
|
let achievement_defs = Arc::new(load_achievement_definitions(&cfg.data_dir)?);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Loaded {} cards, {} packs, {} objectives, {} SBCs",
|
"Loaded {} cards, {} packs, {} objectives, {} SBCs, {} events, {} chemistry styles, {} achievements",
|
||||||
card_db.cards.len(),
|
card_db.cards.len(),
|
||||||
pack_defs.len(),
|
pack_defs.len(),
|
||||||
obj_defs.len(),
|
obj_defs.len(),
|
||||||
sbc_defs.len(),
|
sbc_defs.len(),
|
||||||
|
event_defs.len(),
|
||||||
|
chem_styles.len(),
|
||||||
|
achievement_defs.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -54,19 +98,24 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
pack_defs,
|
pack_defs,
|
||||||
obj_defs: obj_defs.clone(),
|
obj_defs: obj_defs.clone(),
|
||||||
sbc_defs,
|
sbc_defs,
|
||||||
|
event_defs: event_defs.clone(),
|
||||||
|
chem_styles,
|
||||||
|
achievement_defs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background task: refresh NPC market at startup and every 24 hours
|
// Background task: refresh NPC market at startup and every 24 hours
|
||||||
{
|
{
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let card_db = card_db.clone();
|
let card_db = card_db.clone();
|
||||||
|
let event_defs_bg = event_defs.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).await {
|
||||||
tracing::warn!("initial market refresh failed: {e}");
|
tracing::warn!("initial market refresh failed: {e}");
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(86_400)).await;
|
||||||
if let Err(e) = market::refresh_npc_listings(&pool, &card_db).await {
|
if let Err(e) = market::refresh_npc_listings(&pool, &card_db, &event_defs_bg).await
|
||||||
|
{
|
||||||
tracing::warn!("market refresh failed: {e}");
|
tracing::warn!("market refresh failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,24 +161,88 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/health", get(routes::health::get_health))
|
.route("/health", get(routes::health::get_health))
|
||||||
.route("/formations", get(routes::health::get_formations))
|
.route("/formations", get(routes::health::get_formations))
|
||||||
.route("/auth/local", post(routes::auth::post_auth_local))
|
.route("/auth/local", post(routes::auth::post_auth_local))
|
||||||
|
.route("/auth/status", get(routes::auth::get_auth_status))
|
||||||
|
.route("/auth/reset", post(routes::auth::post_auth_reset))
|
||||||
.route("/profile", get(routes::profile::get_profile))
|
.route("/profile", get(routes::profile::get_profile))
|
||||||
.route("/club", get(routes::club::get_club))
|
.route("/club", get(routes::club::get_club))
|
||||||
|
.route("/club", put(routes::club::put_club))
|
||||||
|
.route("/club/checkin", get(routes::club::get_checkin_status))
|
||||||
|
.route("/club/checkin", post(routes::club::post_checkin))
|
||||||
|
.route("/club/milestones", get(routes::club::get_milestones))
|
||||||
.route("/cards", get(routes::cards::get_cards))
|
.route("/cards", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
|
.route("/economy/balance", get(routes::economy::get_balance))
|
||||||
|
.route(
|
||||||
|
"/economy/entitlements",
|
||||||
|
get(routes::economy::get_entitlements),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-entitlement",
|
||||||
|
post(routes::economy::post_purchase_entitlement),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/redeem-entitlement",
|
||||||
|
post(routes::economy::post_redeem_entitlement),
|
||||||
|
)
|
||||||
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/economy/grant-reward",
|
||||||
|
post(routes::economy::post_grant_reward),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-item",
|
||||||
|
post(routes::economy::post_purchase_item),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-items",
|
||||||
|
post(routes::economy::post_purchase_items),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id",
|
||||||
|
delete(routes::cards::delete_owned_card),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/chemistry-style",
|
||||||
|
post(routes::upgrades::post_apply_chemistry_style),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/position",
|
||||||
|
post(routes::upgrades::post_change_position),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/collection/:owned_card_id/training",
|
||||||
|
post(routes::upgrades::post_apply_training),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/chemistry-styles",
|
||||||
|
get(routes::upgrades::get_chemistry_styles),
|
||||||
|
)
|
||||||
.route("/packs", get(routes::packs::get_packs))
|
.route("/packs", get(routes::packs::get_packs))
|
||||||
|
.route("/packs/store", get(routes::packs::get_pack_store))
|
||||||
|
.route("/packs/history", get(routes::packs::get_pack_history))
|
||||||
.route("/packs/buy", post(routes::packs::post_buy_pack))
|
.route("/packs/buy", post(routes::packs::post_buy_pack))
|
||||||
.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))
|
||||||
.route("/objectives", get(routes::objectives::get_objectives))
|
.route("/objectives", get(routes::objectives::get_objectives))
|
||||||
|
.route(
|
||||||
|
"/objectives/:objective_id",
|
||||||
|
get(routes::objectives::get_objective),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/objectives/claim",
|
"/objectives/claim",
|
||||||
post(routes::objectives::post_claim_objective),
|
post(routes::objectives::post_claim_objective),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/objectives/:objective_id/claim",
|
||||||
|
post(routes::objectives::post_claim_objective_by_id),
|
||||||
|
)
|
||||||
.route("/matches", get(routes::matches::get_matches))
|
.route("/matches", get(routes::matches::get_matches))
|
||||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||||
.route("/matches/result", post(routes::matches::post_match_result))
|
.route("/matches/result", post(routes::matches::post_match_result))
|
||||||
@@ -139,7 +252,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/market", get(routes::market::get_market))
|
.route("/market", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.route("/market/sell", post(routes::market::post_market_sell))
|
||||||
|
.route(
|
||||||
|
"/market/trade-history",
|
||||||
|
get(routes::market::get_trade_history),
|
||||||
|
)
|
||||||
.route("/market/refresh", post(routes::market::post_market_refresh))
|
.route("/market/refresh", post(routes::market::post_market_refresh))
|
||||||
|
.route("/market/my-listings", get(routes::market::get_my_listings))
|
||||||
|
.route(
|
||||||
|
"/market/listings/:listing_id",
|
||||||
|
delete(routes::market::delete_market_listing),
|
||||||
|
)
|
||||||
.route("/statistics", get(routes::statistics::get_statistics))
|
.route("/statistics", get(routes::statistics::get_statistics))
|
||||||
.route(
|
.route(
|
||||||
"/statistics/history",
|
"/statistics/history",
|
||||||
@@ -147,7 +269,70 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
)
|
)
|
||||||
.route("/settings", get(routes::settings::get_settings))
|
.route("/settings", get(routes::settings::get_settings))
|
||||||
.route("/settings", put(routes::settings::put_settings))
|
.route("/settings", put(routes::settings::put_settings))
|
||||||
|
.route("/division", get(routes::division::get_division))
|
||||||
|
.route(
|
||||||
|
"/division/history",
|
||||||
|
get(routes::division::get_division_history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/division/leaderboard",
|
||||||
|
get(routes::division::get_division_leaderboard),
|
||||||
|
)
|
||||||
|
.route("/achievements", get(routes::achievements::get_achievements))
|
||||||
|
.route(
|
||||||
|
"/notifications",
|
||||||
|
get(routes::notifications::get_notifications),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/notifications/read-all",
|
||||||
|
post(routes::notifications::mark_all_notifications_read),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/notifications/:id/read",
|
||||||
|
patch(routes::notifications::mark_notification_read),
|
||||||
|
)
|
||||||
|
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
|
||||||
|
.route(
|
||||||
|
"/fut-champs/start",
|
||||||
|
post(routes::fut_champs::post_start_fut_champs),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fut-champs/history",
|
||||||
|
get(routes::fut_champs::get_champs_history),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fut-champs/:session_id/result",
|
||||||
|
post(routes::fut_champs::post_champs_result),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/fut-champs/:session_id/claim",
|
||||||
|
post(routes::fut_champs::post_claim_champs_rewards),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/rivals/claim-weekly",
|
||||||
|
post(routes::fut_champs::post_claim_rivals_reward),
|
||||||
|
)
|
||||||
.route("/draft/squad", get(routes::draft::get_draft_squad))
|
.route("/draft/squad", get(routes::draft::get_draft_squad))
|
||||||
|
.route("/draft/start", post(routes::draft::post_draft_start))
|
||||||
|
.route("/draft/sessions/:id", get(routes::draft::get_draft_session))
|
||||||
|
.route(
|
||||||
|
"/draft/sessions/:id/pick",
|
||||||
|
post(routes::draft::post_draft_pick),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/draft/sessions/:id/abandon",
|
||||||
|
post(routes::draft::post_draft_abandon),
|
||||||
|
)
|
||||||
|
.route("/events", get(routes::events::get_events))
|
||||||
|
.route("/events/:event_id", get(routes::events::get_event))
|
||||||
|
.route(
|
||||||
|
"/events/:event_id/activate",
|
||||||
|
post(routes::events::post_activate_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/events/:event_id/deactivate",
|
||||||
|
post(routes::events::post_deactivate_event),
|
||||||
|
)
|
||||||
// Layers run outermost-first (last added = outermost).
|
// Layers run outermost-first (last added = outermost).
|
||||||
// CORS → body limit → concurrency limit → request ID → trace
|
// CORS → body limit → concurrency limit → request ID → trace
|
||||||
.layer(PropagateRequestIdLayer::x_request_id())
|
.layer(PropagateRequestIdLayer::x_request_id())
|
||||||
|
|||||||
+30
-1
@@ -1,12 +1,21 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub listen_addr: String,
|
pub listen_addr: String,
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
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>,
|
||||||
|
/// Explicit PRODUCTION content pack file paths (each a `CardDefinition[]`
|
||||||
|
/// JSON), loaded IN ADDITION to `data/cards` and any dev pack. This is the
|
||||||
|
/// production real-profile content path — deliberately NOT gated behind the
|
||||||
|
/// dev-only `dev_content_games`.
|
||||||
|
pub content_packs: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -20,6 +29,26 @@ 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(),
|
||||||
|
content_packs: std::env::var("OPENFUT_CONTENT_PACKS")
|
||||||
|
.ok()
|
||||||
|
.map(|v| {
|
||||||
|
v.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
use sqlx::{
|
||||||
|
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||||
|
ConnectOptions, Connection, SqlitePool,
|
||||||
|
};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub type Pool = SqlitePool;
|
pub type Pool = SqlitePool;
|
||||||
|
|
||||||
pub async fn init_pool(database_url: &str) -> Result<Pool> {
|
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
||||||
info!("Connecting to database: {}", database_url);
|
info!("Connecting to database: {}", database_url);
|
||||||
|
// Per-connection options so EVERY pooled connection gets them: WAL for
|
||||||
|
// reader/writer concurrency, foreign keys on, and a busy_timeout so a
|
||||||
|
// transient SQLITE_BUSY under concurrent access waits-and-retries.
|
||||||
|
let opts = SqliteConnectOptions::from_str(database_url)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_secs(5));
|
||||||
|
// Establish WAL on the file via ONE connection BEFORE the pool opens.
|
||||||
|
// Switching a fresh DB to WAL is a one-time file-level change; letting
|
||||||
|
// several pooled connections do it concurrently at warm-up races that
|
||||||
|
// switch and can surface a spurious lock. Serialize it here so every
|
||||||
|
// pooled connection thereafter only re-asserts an already-WAL file.
|
||||||
|
{
|
||||||
|
let mut conn = opts.clone().connect().await?;
|
||||||
|
sqlx::query("PRAGMA journal_mode=WAL")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
conn.close().await?;
|
||||||
|
}
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(max_connections)
|
||||||
.connect(database_url)
|
.connect_with(opts)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Request extractors shared across routes.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
async_trait,
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{request::Parts, HeaderName},
|
||||||
|
};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
|
||||||
|
///
|
||||||
|
/// Multi-game support: each game bridge tags its requests with its own id
|
||||||
|
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
|
||||||
|
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
|
||||||
|
/// when the header is absent, so the existing FIFA 23 bridge and the integration
|
||||||
|
/// tests (which send no header) keep operating on their game unchanged.
|
||||||
|
///
|
||||||
|
/// Extraction never fails: a missing or malformed header falls back to the default.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GameId(pub String);
|
||||||
|
|
||||||
|
/// The game assumed when no `X-OpenFUT-Game` header is present.
|
||||||
|
pub const DEFAULT_GAME: &str = "fifa23";
|
||||||
|
|
||||||
|
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
|
||||||
|
|
||||||
|
impl GameId {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S> FromRequestParts<S> for GameId
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let game = parts
|
||||||
|
.headers
|
||||||
|
.get(&HEADER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_GAME)
|
||||||
|
.to_string();
|
||||||
|
Ok(GameId(game))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod app;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod extractors;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod modding;
|
pub mod modding;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
@@ -20,6 +21,8 @@ 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(),
|
||||||
|
content_packs: Vec::new(),
|
||||||
};
|
};
|
||||||
app::build(pool, cfg).await
|
app::build(pool, cfg).await
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-3
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use openfut_core::{config, db, seed};
|
use openfut_core::{config, db, seed};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
@@ -12,13 +12,54 @@ async fn main() -> Result<()> {
|
|||||||
EnvFilter::try_from_default_env()
|
EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
|
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
// Diagnostics on stderr so stdout carries only machine output (the
|
||||||
|
// `import`/`seed-dev` subcommands print a clean JSON result there).
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||||
.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(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt-in generic import subcommand: `openfut-core import <request.json>`.
|
||||||
|
// Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates
|
||||||
|
// FIFA17 source data into it), loads production content packs, runs preflight,
|
||||||
|
// and applies one all-or-nothing transaction. FIFA17 semantics live entirely
|
||||||
|
// in the adapter; Core only sees opaque ids + opaque extension bytes.
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("import") {
|
||||||
|
let path = std::env::args()
|
||||||
|
.nth(2)
|
||||||
|
.context("usage: openfut-core import <request.json>")?;
|
||||||
|
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)?;
|
||||||
|
for pack in &cfg.content_packs {
|
||||||
|
card_db.load_pack(pack)?;
|
||||||
|
}
|
||||||
|
let raw = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read import request {path}"))?;
|
||||||
|
let req: openfut_core::services::import::ProfileImportRequest =
|
||||||
|
serde_json::from_str(&raw).context("parse import request JSON")?;
|
||||||
|
let outcome =
|
||||||
|
openfut_core::services::import::apply_profile_import(&pool, &card_db, &req).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||||
|
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).await?;
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
seed::maybe_seed(&pool).await?;
|
seed::maybe_seed(&pool).await?;
|
||||||
|
|||||||
+1
-24
@@ -1,24 +1 @@
|
|||||||
use anyhow::{Context, Result};
|
// Reserved for future modding loader utilities.
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Generic loader for JSON arrays from a directory.
|
|
||||||
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
|
||||||
let mut items = Vec::new();
|
|
||||||
if !dir.exists() {
|
|
||||||
return Ok(items);
|
|
||||||
}
|
|
||||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
|
||||||
let content =
|
|
||||||
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
|
||||||
let batch: Vec<T> =
|
|
||||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
|
||||||
items.extend(batch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
//! Modding support: load JSON data files from the data/ directory.
|
//! Modding support: load JSON data files from the data/ directory.
|
||||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||||
|
|
||||||
pub mod loader;
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AchievementDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub trigger: String,
|
||||||
|
pub threshold: i64,
|
||||||
|
pub reward_coins: i64,
|
||||||
|
pub rarity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct PlayerAchievement {
|
||||||
|
pub id: String,
|
||||||
|
pub achievement_id: String,
|
||||||
|
pub unlocked_at: String,
|
||||||
|
}
|
||||||
@@ -13,6 +13,47 @@ pub enum Rarity {
|
|||||||
Icon,
|
Icon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Rarity {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Rarity::Bronze => "bronze",
|
||||||
|
Rarity::Silver => "silver",
|
||||||
|
Rarity::Gold => "gold",
|
||||||
|
Rarity::RareGold => "raregold",
|
||||||
|
Rarity::Totw => "totw",
|
||||||
|
Rarity::Hero => "hero",
|
||||||
|
Rarity::Icon => "icon",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
@@ -42,4 +83,7 @@ pub struct OwnedCard {
|
|||||||
pub is_loan: bool,
|
pub is_loan: bool,
|
||||||
pub loan_matches_remaining: Option<i64>,
|
pub loan_matches_remaining: Option<i64>,
|
||||||
pub acquired_at: String,
|
pub acquired_at: String,
|
||||||
|
pub chemistry_style: String,
|
||||||
|
pub position_override: Option<String>,
|
||||||
|
pub training_bonus: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatBoosts {
|
||||||
|
pub pace: i32,
|
||||||
|
pub shooting: i32,
|
||||||
|
pub passing: i32,
|
||||||
|
pub dribbling: i32,
|
||||||
|
pub defending: i32,
|
||||||
|
pub physical: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChemistryStyle {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub boosts: StatBoosts,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_chemistry_styles(data_dir: &str) -> anyhow::Result<Vec<ChemistryStyle>> {
|
||||||
|
let path = format!("{data_dir}/chemistry_styles.json");
|
||||||
|
let raw = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read {path}: {e}"))?;
|
||||||
|
Ok(serde_json::from_str(&raw)?)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub struct Club {
|
|||||||
pub level: i64,
|
pub level: i64,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub manager_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Club {
|
impl Club {
|
||||||
@@ -24,6 +25,7 @@ impl Club {
|
|||||||
level: 1,
|
level: 1,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
manager_name: Some("Player Manager".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct DraftSession {
|
||||||
|
pub id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub difficulty: String,
|
||||||
|
/// JSON-encoded Vec<String> — position labels in pick order
|
||||||
|
pub pick_order: String,
|
||||||
|
/// JSON-encoded Vec<String> — card_ids picked so far
|
||||||
|
pub picks: String,
|
||||||
|
/// JSON-encoded Vec<String> — card_ids offered for the current slot (None if complete/abandoned)
|
||||||
|
pub current_candidates: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub reward_coins: i64,
|
||||||
|
pub reward_pack_id: Option<String>,
|
||||||
|
pub squad_rating: i64,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default 4-3-3 position pick order for a draft.
|
||||||
|
pub const DRAFT_PICK_ORDER: &[&str] = &[
|
||||||
|
"GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CAM", "RW", "ST", "LW",
|
||||||
|
];
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EventType {
|
||||||
|
Totw,
|
||||||
|
Seasonal,
|
||||||
|
Special,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PackWeightBoost {
|
||||||
|
pub rarity: String,
|
||||||
|
pub multiplier: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EventEffects {
|
||||||
|
pub pack_weight_boosts: Vec<PackWeightBoost>,
|
||||||
|
pub bonus_market_cards: Vec<String>,
|
||||||
|
pub unlocks_sbc_ids: Vec<String>,
|
||||||
|
pub unlocks_objective_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EventDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub event_type: EventType,
|
||||||
|
pub starts_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub is_manual: bool,
|
||||||
|
pub effects: EventEffects,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event definition bundled with its current runtime active status.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct EventWithStatus {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub definition: EventDefinition,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub activated_at: Option<String>,
|
||||||
|
pub deactivated_at: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const MAX_MATCHES: i64 = 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct FutChampsSession {
|
||||||
|
pub id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub week_number: i64,
|
||||||
|
pub matches_played: i64,
|
||||||
|
pub wins: i64,
|
||||||
|
pub draws: i64,
|
||||||
|
pub losses: i64,
|
||||||
|
pub status: String,
|
||||||
|
pub reward_tier: Option<String>,
|
||||||
|
pub reward_coins: Option<i64>,
|
||||||
|
pub reward_pack_id: Option<String>,
|
||||||
|
pub reward_claimed: bool,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FutChampsSession {
|
||||||
|
pub fn matches_remaining(&self) -> i64 {
|
||||||
|
(MAX_MATCHES - self.matches_played).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.status == "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reward tier based on wins out of 30 matches — mirrors FUT 23 structure.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum ChampsTier {
|
||||||
|
Elite,
|
||||||
|
WorldClass,
|
||||||
|
Gold3,
|
||||||
|
Gold2,
|
||||||
|
Gold1,
|
||||||
|
Silver3,
|
||||||
|
Silver2,
|
||||||
|
Silver1,
|
||||||
|
Bronze3,
|
||||||
|
Bronze2,
|
||||||
|
Bronze1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChampsTier {
|
||||||
|
pub fn from_wins(wins: i64) -> Self {
|
||||||
|
match wins {
|
||||||
|
27.. => Self::Elite,
|
||||||
|
23..=26 => Self::WorldClass,
|
||||||
|
20..=22 => Self::Gold3,
|
||||||
|
17..=19 => Self::Gold2,
|
||||||
|
14..=16 => Self::Gold1,
|
||||||
|
11..=13 => Self::Silver3,
|
||||||
|
8..=10 => Self::Silver2,
|
||||||
|
5..=7 => Self::Silver1,
|
||||||
|
2..=4 => Self::Bronze3,
|
||||||
|
1 => Self::Bronze2,
|
||||||
|
_ => Self::Bronze1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Elite => "Elite",
|
||||||
|
Self::WorldClass => "World Class",
|
||||||
|
Self::Gold3 => "Gold 3",
|
||||||
|
Self::Gold2 => "Gold 2",
|
||||||
|
Self::Gold1 => "Gold 1",
|
||||||
|
Self::Silver3 => "Silver 3",
|
||||||
|
Self::Silver2 => "Silver 2",
|
||||||
|
Self::Silver1 => "Silver 1",
|
||||||
|
Self::Bronze3 => "Bronze 3",
|
||||||
|
Self::Bronze2 => "Bronze 2",
|
||||||
|
Self::Bronze1 => "Bronze 1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reward_coins(&self) -> i64 {
|
||||||
|
match self {
|
||||||
|
Self::Elite => 50_000,
|
||||||
|
Self::WorldClass => 30_000,
|
||||||
|
Self::Gold3 => 15_000,
|
||||||
|
Self::Gold2 => 10_000,
|
||||||
|
Self::Gold1 => 7_500,
|
||||||
|
Self::Silver3 => 5_000,
|
||||||
|
Self::Silver2 => 3_500,
|
||||||
|
Self::Silver1 => 2_000,
|
||||||
|
Self::Bronze3 => 1_000,
|
||||||
|
Self::Bronze2 => 500,
|
||||||
|
Self::Bronze1 => 250,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reward_pack(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Elite => Some("icon_pack"),
|
||||||
|
Self::WorldClass => Some("rare_gold_pack"),
|
||||||
|
Self::Gold3 | Self::Gold2 | Self::Gold1 => Some("gold_pack"),
|
||||||
|
Self::Silver3 | Self::Silver2 | Self::Silver1 => Some("silver_pack"),
|
||||||
|
Self::Bronze3 => Some("bronze_pack"),
|
||||||
|
Self::Bronze2 | Self::Bronze1 => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::models::{achievement::AchievementDefinition, profile::LevelUpEvent};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -77,4 +78,12 @@ pub struct MatchRewardResult {
|
|||||||
pub coins_awarded: i64,
|
pub coins_awarded: i64,
|
||||||
pub xp_awarded: i64,
|
pub xp_awarded: i64,
|
||||||
pub objectives_updated: Vec<String>,
|
pub objectives_updated: Vec<String>,
|
||||||
|
/// Owned card IDs removed because the loan expired this match.
|
||||||
|
pub expired_loans: Vec<String>,
|
||||||
|
/// Present when this match completed the current season.
|
||||||
|
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
||||||
|
/// Non-empty when the player levelled up one or more times from this match's XP.
|
||||||
|
pub level_ups: Vec<LevelUpEvent>,
|
||||||
|
/// Achievements unlocked as a result of this match.
|
||||||
|
pub achievements_unlocked: Vec<AchievementDefinition>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
pub mod achievement;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod chemistry_style;
|
||||||
|
pub mod notification;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod draft;
|
||||||
|
pub mod fut_champs;
|
||||||
|
pub mod game_ext;
|
||||||
|
pub mod event;
|
||||||
|
pub mod season;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub is_read: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notification {
|
||||||
|
pub fn new(kind: &str, title: &str, body: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
body: body.to_string(),
|
||||||
|
is_read: false,
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,19 @@ pub enum ObjectiveMetric {
|
|||||||
CoinsEarned,
|
CoinsEarned,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ObjectiveMetric {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ObjectiveMetric::MatchesWon => "matcheswon",
|
||||||
|
ObjectiveMetric::MatchesPlayed => "matchesplayed",
|
||||||
|
ObjectiveMetric::GoalsScored => "goalsscored",
|
||||||
|
ObjectiveMetric::PacksOpened => "packsopened",
|
||||||
|
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
|
||||||
|
ObjectiveMetric::CoinsEarned => "coinsearned",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Objective definition from data/objectives/*.json
|
/// Objective definition from data/objectives/*.json
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObjectiveDefinition {
|
pub struct ObjectiveDefinition {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub struct Pack {
|
|||||||
pub definition_id: String,
|
pub definition_id: String,
|
||||||
pub opened: bool,
|
pub opened: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub opened_cards: Option<String>,
|
||||||
|
pub opened_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The result of opening a pack.
|
/// The result of opening a pack.
|
||||||
|
|||||||
+61
-1
@@ -8,18 +8,22 @@ pub struct Profile {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub level: i64,
|
pub level: i64,
|
||||||
pub xp: i64,
|
pub xp: i64,
|
||||||
|
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
|
||||||
|
/// this profile's downstream state so multiple games share one core + DB.
|
||||||
|
pub game_id: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
pub fn new(username: impl Into<String>) -> Self {
|
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
|
game_id: game_id.into(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
@@ -30,3 +34,59 @@ impl Profile {
|
|||||||
pub struct CreateProfileRequest {
|
pub struct CreateProfileRequest {
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// XP required to reach each level (cumulative total from level 1).
|
||||||
|
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
|
||||||
|
pub const XP_THRESHOLDS: &[i64] = &[
|
||||||
|
0, // level 1
|
||||||
|
500, // level 2
|
||||||
|
1200, // level 3
|
||||||
|
2000, // level 4
|
||||||
|
3000, // level 5
|
||||||
|
4200, // level 6
|
||||||
|
5600, // level 7
|
||||||
|
7200, // level 8
|
||||||
|
9000, // level 9
|
||||||
|
11000, // level 10
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Compute the level for a given cumulative XP total.
|
||||||
|
pub fn level_for_xp(total_xp: i64) -> i64 {
|
||||||
|
let base = XP_THRESHOLDS
|
||||||
|
.iter()
|
||||||
|
.rposition(|&t| total_xp >= t)
|
||||||
|
.map(|i| i as i64 + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
|
// Beyond the table: every 2500 XP is another level.
|
||||||
|
let overflow_xp = total_xp - XP_THRESHOLDS.last().copied().unwrap_or(0);
|
||||||
|
if overflow_xp > 0 {
|
||||||
|
let table_max = XP_THRESHOLDS.len() as i64;
|
||||||
|
table_max + (overflow_xp / 2500)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coins awarded per new level gained.
|
||||||
|
pub fn coins_for_level(new_level: i64) -> i64 {
|
||||||
|
new_level * 500
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack granted at milestone levels (5, 10, 15, 20, …).
|
||||||
|
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
|
||||||
|
match new_level {
|
||||||
|
5 => Some("bronze_pack"),
|
||||||
|
10 => Some("silver_pack"),
|
||||||
|
15 => Some("gold_pack"),
|
||||||
|
20 => Some("rare_gold_pack"),
|
||||||
|
l if l > 20 && l % 5 == 0 => Some("gold_pack"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LevelUpEvent {
|
||||||
|
pub new_level: i64,
|
||||||
|
pub coins_granted: i64,
|
||||||
|
pub pack_granted: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Season {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub division: i64,
|
||||||
|
pub season_number: i64,
|
||||||
|
pub season_points: i64,
|
||||||
|
pub matches_played: i64,
|
||||||
|
pub wins: i64,
|
||||||
|
pub draws: i64,
|
||||||
|
pub losses: i64,
|
||||||
|
pub started_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct SeasonHistoryEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub season_number: i64,
|
||||||
|
pub division: i64,
|
||||||
|
pub season_points: i64,
|
||||||
|
pub wins: i64,
|
||||||
|
pub draws: i64,
|
||||||
|
pub losses: i64,
|
||||||
|
pub result: String,
|
||||||
|
pub new_division: i64,
|
||||||
|
pub coins_awarded: i64,
|
||||||
|
pub pack_awarded: Option<String>,
|
||||||
|
pub ended_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SEASON_LENGTH: i64 = 10;
|
||||||
|
pub const PROMOTION_PTS: i64 = 21;
|
||||||
|
pub const RELEGATION_PTS: i64 = 3;
|
||||||
|
const MIN_DIVISION: i64 = 1;
|
||||||
|
const MAX_DIVISION: i64 = 10;
|
||||||
|
|
||||||
|
impl Season {
|
||||||
|
/// Whether this season's match quota is complete.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.matches_played >= SEASON_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Season end result: promote / maintain / relegate.
|
||||||
|
pub fn end_result(&self) -> SeasonResult {
|
||||||
|
if self.season_points >= PROMOTION_PTS && self.division > MIN_DIVISION {
|
||||||
|
SeasonResult::Promoted
|
||||||
|
} else if self.season_points <= RELEGATION_PTS && self.division < MAX_DIVISION {
|
||||||
|
SeasonResult::Relegated
|
||||||
|
} else {
|
||||||
|
SeasonResult::Maintained
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coin reward for ending the season in this division.
|
||||||
|
pub fn season_reward_coins(&self) -> i64 {
|
||||||
|
match self.division {
|
||||||
|
1 => 5000,
|
||||||
|
2 => 4000,
|
||||||
|
3 => 3000,
|
||||||
|
4 => 2000,
|
||||||
|
5 => 1500,
|
||||||
|
6 | 7 => 1000,
|
||||||
|
_ => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack reward (if any) for ending the season in this division.
|
||||||
|
pub fn season_reward_pack(&self) -> Option<&'static str> {
|
||||||
|
match self.division {
|
||||||
|
1 | 2 => Some("gold_pack"),
|
||||||
|
3 | 4 => Some("silver_pack"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches left in the current season.
|
||||||
|
pub fn matches_remaining(&self) -> i64 {
|
||||||
|
(SEASON_LENGTH - self.matches_played).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points needed for promotion (None if already at div 1).
|
||||||
|
pub fn pts_for_promotion(&self) -> Option<i64> {
|
||||||
|
if self.division <= MIN_DIVISION {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((PROMOTION_PTS - self.season_points).max(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points above the relegation threshold (always ≥ 0 means safe).
|
||||||
|
pub fn pts_above_safe(&self) -> i64 {
|
||||||
|
self.season_points - RELEGATION_PTS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether relegation is still possible (false if at max division already).
|
||||||
|
pub fn can_be_relegated(&self) -> bool {
|
||||||
|
self.division < MAX_DIVISION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether promotion is still achievable given matches remaining.
|
||||||
|
pub fn promotion_achievable(&self) -> bool {
|
||||||
|
self.division > MIN_DIVISION
|
||||||
|
&& self.season_points + self.matches_remaining() * 3 >= PROMOTION_PTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SeasonResult {
|
||||||
|
Promoted,
|
||||||
|
Maintained,
|
||||||
|
Relegated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned in match result when a season ends.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SeasonEndSummary {
|
||||||
|
pub result: SeasonResult,
|
||||||
|
pub old_division: i64,
|
||||||
|
pub new_division: i64,
|
||||||
|
pub new_season_number: i64,
|
||||||
|
pub coins_awarded: i64,
|
||||||
|
pub pack_awarded: Option<String>,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> 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 _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
|
||||||
|
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
|
||||||
|
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"achievements": achievements,
|
||||||
|
"earned": earned,
|
||||||
|
"total": achievements.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
+110
-2
@@ -1,9 +1,11 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, 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},
|
||||||
|
extractors::GameId,
|
||||||
models::{club::Club, profile::CreateProfileRequest},
|
models::{club::Club, profile::CreateProfileRequest},
|
||||||
seed,
|
seed,
|
||||||
services::{club as club_svc, profile as profile_svc},
|
services::{club as club_svc, profile as profile_svc},
|
||||||
@@ -11,11 +13,12 @@ use crate::{
|
|||||||
|
|
||||||
pub async fn post_auth_local(
|
pub async fn post_auth_local(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<CreateProfileRequest>,
|
Json(req): Json<CreateProfileRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||||
|
|
||||||
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
|
||||||
|
|
||||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||||
club_svc::create_club(&state.pool, &club).await?;
|
club_svc::create_club(&state.pool, &club).await?;
|
||||||
@@ -28,3 +31,108 @@ pub async fn post_auth_local(
|
|||||||
"message": "Welcome to OpenFUT FC! Your club has been created."
|
"message": "Welcome to OpenFUT FC! Your club has been created."
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /auth/status — lightweight check: does a profile exist?
|
||||||
|
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
||||||
|
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||||
|
.bind(game.as_str())
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "has_profile": count > 0 })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetRequest {
|
||||||
|
pub confirm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /auth/reset — wipe all game data and start fresh.
|
||||||
|
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
|
||||||
|
/// accidental or unauthenticated calls. Deletes every user-data table in
|
||||||
|
/// dependency order; schema (migrations) is preserved.
|
||||||
|
pub async fn post_auth_reset(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<ResetRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
if req.confirm.as_deref() != Some("reset") {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
|
||||||
|
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
|
||||||
|
// delete their dependent rows in reverse-dependency order.
|
||||||
|
let profile_id: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(game.as_str())
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(profile_id) = profile_id else {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"reset": true,
|
||||||
|
"message": "nothing to reset for this game"
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
|
||||||
|
.bind(&profile_id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for club_id in &club_ids {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
for table in ["squads", "owned_cards", "packs", "market_history"] {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for table in [
|
||||||
|
"fut_champs_sessions",
|
||||||
|
"sbc_submissions",
|
||||||
|
"objective_progress",
|
||||||
|
"position_goals",
|
||||||
|
"statistics",
|
||||||
|
"seasons",
|
||||||
|
"season_history",
|
||||||
|
"matches",
|
||||||
|
"draft_sessions",
|
||||||
|
"daily_checkins",
|
||||||
|
] {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
|
||||||
|
.bind(&profile_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
|
||||||
|
.bind(&profile_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||||
|
.bind(&profile_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// NOTE (follow-up): settings (global key/value), events (shared content),
|
||||||
|
// market_listings (keyed by seller_name, includes the shared NPC market),
|
||||||
|
// notifications and player_achievements are not yet game-scoped and are left
|
||||||
|
// intact. They need a game/profile key before a per-game reset can cover them.
|
||||||
|
tracing::info!(game = %game.as_str(), "Per-game reset performed");
|
||||||
|
Ok(Json(json!({
|
||||||
|
"reset": true,
|
||||||
|
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
+134
-21
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -9,13 +10,32 @@ 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.
|
||||||
|
fn quick_sell_coins(overall: u8) -> i64 {
|
||||||
|
if overall >= 85 { 1500 }
|
||||||
|
else if overall >= 80 { 900 }
|
||||||
|
else if overall >= 75 { 600 }
|
||||||
|
else if overall >= 65 { 300 }
|
||||||
|
else { 150 }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CardQuery {
|
pub struct CardQuery {
|
||||||
pub rarity: Option<String>,
|
pub rarity: Option<String>,
|
||||||
pub position: Option<String>,
|
pub position: Option<String>,
|
||||||
|
pub nation: Option<String>,
|
||||||
|
pub league: Option<String>,
|
||||||
|
pub club: Option<String>,
|
||||||
|
pub min_overall: Option<u8>,
|
||||||
|
pub max_overall: Option<u8>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_card(
|
pub async fn get_card(
|
||||||
@@ -33,54 +53,147 @@ pub async fn get_cards(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<CardQuery>,
|
Query(query): Query<CardQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let cards = state
|
let mut cards: Vec<_> = state
|
||||||
.card_db
|
.card_db
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
query
|
let rarity_ok = query
|
||||||
.rarity
|
.rarity
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true);
|
||||||
&& query
|
let pos_ok = query
|
||||||
.position
|
.position
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| c.position.to_lowercase() == p.to_lowercase())
|
.map(|p| c.position.eq_ignore_ascii_case(p))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true);
|
||||||
|
let nation_ok = query
|
||||||
|
.nation
|
||||||
|
.as_ref()
|
||||||
|
.map(|n| c.nation.eq_ignore_ascii_case(n))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let league_ok = query
|
||||||
|
.league
|
||||||
|
.as_ref()
|
||||||
|
.map(|l| c.league.eq_ignore_ascii_case(l))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let club_ok = query
|
||||||
|
.club
|
||||||
|
.as_ref()
|
||||||
|
.map(|cl| c.club.eq_ignore_ascii_case(cl))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let min_ok = query.min_overall.map(|m| c.overall >= m).unwrap_or(true);
|
||||||
|
let max_ok = query.max_overall.map(|m| c.overall <= m).unwrap_or(true);
|
||||||
|
rarity_ok && pos_ok && nation_ok && league_ok && club_ok && min_ok && max_ok
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "cards": cards, "total": cards.len() })))
|
cards.sort_by_key(|c| std::cmp::Reverse(c.overall));
|
||||||
|
let total = cards.len();
|
||||||
|
if let Some(limit) = query.limit {
|
||||||
|
cards.truncate(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn get_collection(
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
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 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?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE club_id = ?"
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE club_id = ?"
|
||||||
)
|
)
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.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| {
|
||||||
json!({
|
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||||
|
let effective_position =
|
||||||
|
o.position_override.as_deref().unwrap_or(&def.position);
|
||||||
|
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,
|
||||||
"acquired_at": o.acquired_at,
|
"acquired_at": o.acquired_at,
|
||||||
|
"chemistry_style": o.chemistry_style,
|
||||||
|
"position_override": o.position_override,
|
||||||
|
"training_bonus": o.training_bonus,
|
||||||
|
"effective_overall": effective_overall,
|
||||||
|
"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.
|
||||||
|
pub async fn delete_owned_card(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
) -> 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 owned = sqlx::query_as::<_, OwnedCard>(
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
|
FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&owned_card_id)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
||||||
|
|
||||||
|
let card = state
|
||||||
|
.card_db
|
||||||
|
.get(&owned.card_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
|
||||||
|
|
||||||
|
let coins = quick_sell_coins(card.overall);
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||||
|
.bind(&owned_card_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
club_svc::add_coins(&state.pool, &club.id, coins).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"quick_sold": owned_card_id,
|
||||||
|
"card_id": owned.card_id,
|
||||||
|
"coins_received": coins,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-3
@@ -1,13 +1,126 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::club::Club,
|
models::club::Club,
|
||||||
services::{club as club_svc, profile as profile_svc},
|
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
|
||||||
};
|
};
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
Ok(Json(club))
|
Ok(Json(club))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateClubRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub manager_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_club(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<UpdateClubRequest>,
|
||||||
|
) -> 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 updated = club_svc::update_club(
|
||||||
|
&state.pool,
|
||||||
|
&club.id,
|
||||||
|
req.name.as_deref(),
|
||||||
|
req.manager_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "club": updated })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"available": status.available,
|
||||||
|
"streak_day": status.streak_day,
|
||||||
|
"next_reward_coins": status.next_reward_coins,
|
||||||
|
"next_reward_pack": status.next_reward_pack,
|
||||||
|
"last_checked_in": status.last_checked_in,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> 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 r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"coins_awarded": r.coins_awarded,
|
||||||
|
"pack_awarded": r.pack_awarded,
|
||||||
|
"new_streak": r.new_streak,
|
||||||
|
"already_claimed": r.already_claimed,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> 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 stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
let seasons_completed: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&profile.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let highest_division: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&profile.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(10);
|
||||||
|
|
||||||
|
let cards_owned: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let sbcs_completed: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
|
||||||
|
)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let total_checkins: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&profile.id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"total_wins": stats.matches_won,
|
||||||
|
"total_draws": stats.matches_drawn,
|
||||||
|
"total_losses": stats.matches_lost,
|
||||||
|
"total_matches": stats.matches_played,
|
||||||
|
"total_goals_scored": stats.goals_scored,
|
||||||
|
"total_goals_conceded": stats.goals_conceded,
|
||||||
|
"best_win_streak": stats.best_win_streak,
|
||||||
|
"total_packs_opened": stats.packs_opened,
|
||||||
|
"seasons_completed": seasons_completed,
|
||||||
|
"highest_division_reached": highest_division,
|
||||||
|
"cards_owned": cards_owned,
|
||||||
|
"sbcs_completed": sbcs_completed,
|
||||||
|
"total_checkins": total_checkins,
|
||||||
|
"club_level": club.level,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
models::season::{PROMOTION_PTS, RELEGATION_PTS, SEASON_LENGTH},
|
||||||
|
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_division(State(state): State<AppState>, game: GameId) -> 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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"division": season.division,
|
||||||
|
"season_number": season.season_number,
|
||||||
|
"season_points": season.season_points,
|
||||||
|
"matches_played": season.matches_played,
|
||||||
|
"matches_remaining": season.matches_remaining(),
|
||||||
|
"season_length": SEASON_LENGTH,
|
||||||
|
"promotion_pts": PROMOTION_PTS,
|
||||||
|
"relegation_pts": RELEGATION_PTS,
|
||||||
|
"pts_above_safe": season.pts_above_safe(),
|
||||||
|
"promotion_achievable": season.promotion_achievable(),
|
||||||
|
"can_be_relegated": season.can_be_relegated(),
|
||||||
|
"record": {
|
||||||
|
"wins": season.wins,
|
||||||
|
"draws": season.draws,
|
||||||
|
"losses": season.losses,
|
||||||
|
},
|
||||||
|
"pts_for_promotion": season.pts_for_promotion(),
|
||||||
|
"started_at": season.started_at,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
|
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> 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 season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
// Seed from division + season_number so the NPC table is stable within a season
|
||||||
|
let seed = (season.division as u64) * 1000 + season.season_number as u64;
|
||||||
|
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
||||||
|
|
||||||
|
const NPC_NAMES: &[&str] = &[
|
||||||
|
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
|
||||||
|
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
|
||||||
|
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
|
||||||
|
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
|
||||||
|
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pick 9 NPC names without repetition using the seeded RNG
|
||||||
|
let mut name_indices: Vec<usize> = (0..NPC_NAMES.len()).collect();
|
||||||
|
name_indices.sort_by_key(|&_i| rng.gen::<u64>());
|
||||||
|
let npc_names: Vec<&str> = name_indices[..9].iter().map(|&i| NPC_NAMES[i]).collect();
|
||||||
|
|
||||||
|
// Generate NPC records: clubs in top of table have more wins, bottom have more losses
|
||||||
|
let matches_played = season.matches_played;
|
||||||
|
let npc_matches = matches_played.max(1); // NPC clubs play same number of matches as player
|
||||||
|
let mut table: Vec<serde_json::Value> = npc_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, name)| {
|
||||||
|
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
||||||
|
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
|
||||||
|
let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8
|
||||||
|
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||||
|
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
||||||
|
let draws = (npc_matches - wins - losses).max(0);
|
||||||
|
let pts = wins * 3 + draws;
|
||||||
|
json!({
|
||||||
|
"club_name": name,
|
||||||
|
"wins": wins,
|
||||||
|
"draws": draws,
|
||||||
|
"losses": losses,
|
||||||
|
"pts": pts,
|
||||||
|
"matches_played": npc_matches,
|
||||||
|
"is_player": false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Add player club row
|
||||||
|
let player_pts = season.wins * 3 + season.draws;
|
||||||
|
table.push(json!({
|
||||||
|
"club_name": club.name,
|
||||||
|
"wins": season.wins,
|
||||||
|
"draws": season.draws,
|
||||||
|
"losses": season.losses,
|
||||||
|
"pts": player_pts,
|
||||||
|
"matches_played": season.matches_played,
|
||||||
|
"is_player": true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by pts desc, then wins desc
|
||||||
|
table.sort_by(|a, b| {
|
||||||
|
let pts_a = a["pts"].as_i64().unwrap_or(0);
|
||||||
|
let pts_b = b["pts"].as_i64().unwrap_or(0);
|
||||||
|
pts_b.cmp(&pts_a).then_with(|| {
|
||||||
|
let w_b = b["wins"].as_i64().unwrap_or(0);
|
||||||
|
let w_a = a["wins"].as_i64().unwrap_or(0);
|
||||||
|
w_b.cmp(&w_a)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add position numbers
|
||||||
|
let table_with_pos: Vec<serde_json::Value> = table
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, mut entry)| {
|
||||||
|
entry["position"] = json!(i as i64 + 1);
|
||||||
|
entry
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"leaderboard": table_with_pos,
|
||||||
|
"division": season.division,
|
||||||
|
"season_number": season.season_number,
|
||||||
|
"promotion_threshold": 3, // top 3 promote
|
||||||
|
"relegation_threshold": 8, // bottom 2 relegate
|
||||||
|
})))
|
||||||
|
}
|
||||||
+76
-6
@@ -1,27 +1,29 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{app::AppState, error::AppResult, services::match_service};
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{club as club_svc, draft as draft_svc, match_service, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DraftQuery {
|
pub struct DraftQuery {
|
||||||
pub difficulty: Option<String>,
|
pub difficulty: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a randomly generated draft squad from the card pool.
|
/// Legacy endpoint: returns a pre-built random draft squad (no session state).
|
||||||
/// Difficulty controls minimum card overall. The front-end is responsible
|
|
||||||
/// for presenting per-position pick choices; this endpoint provides the pool.
|
|
||||||
pub async fn get_draft_squad(
|
pub async fn get_draft_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<DraftQuery>,
|
Query(query): Query<DraftQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let difficulty = query.difficulty.as_deref().unwrap_or("beginner");
|
let difficulty = query.difficulty.as_deref().unwrap_or("beginner");
|
||||||
let generated = match_service::generate_opponent(&state.card_db, difficulty);
|
let generated = match_service::generate_opponent(&state.card_db, difficulty);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"mode": "draft",
|
"mode": "draft",
|
||||||
"difficulty": difficulty,
|
"difficulty": difficulty,
|
||||||
@@ -30,3 +32,71 @@ pub async fn get_draft_squad(
|
|||||||
"cards": generated["cards"],
|
"cards": generated["cards"],
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a new stateful FUT-style draft session.
|
||||||
|
///
|
||||||
|
/// Returns a session ID and the 5 candidates for the first position (GK).
|
||||||
|
/// The caller picks one candidate, then calls `POST /draft/sessions/:id/pick`
|
||||||
|
/// until all 11 slots are filled.
|
||||||
|
pub async fn post_draft_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Query(query): Query<DraftQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
||||||
|
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current state of a draft session.
|
||||||
|
pub async fn get_draft_session(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PickRequest {
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick one card from the current candidates list.
|
||||||
|
///
|
||||||
|
/// Advances the session to the next position.
|
||||||
|
/// When the last pick is made the session status changes to "completed"
|
||||||
|
/// and rewards (coins + optional pack) are granted automatically.
|
||||||
|
pub async fn post_draft_pick(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
Json(req): Json<PickRequest>,
|
||||||
|
) -> 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 session = draft_svc::pick_card(
|
||||||
|
&state.pool,
|
||||||
|
&state.card_db,
|
||||||
|
&club.id,
|
||||||
|
&profile.id,
|
||||||
|
&session_id,
|
||||||
|
&req.card_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon an active draft session. No rewards are granted.
|
||||||
|
pub async fn post_draft_abandon(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! Generic economy HTTP boundary.
|
||||||
|
//!
|
||||||
|
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
|
||||||
|
//! resolution every other Core route uses ([`GameId`] header → active profile →
|
||||||
|
//! club). The caller (a game host) never supplies a club id; Core maps the game
|
||||||
|
//! to its authoritative club, so there is no cross-club economy access. Every
|
||||||
|
//! op is a single durable SQLite transaction in the service layer.
|
||||||
|
//!
|
||||||
|
//! This surface is deliberately game-neutral: no currency names, pack ids, or
|
||||||
|
//! wire semantics — those live in the game host/adapter.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
extractors::GameId,
|
||||||
|
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve the game-scoped active profile's club id.
|
||||||
|
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
|
||||||
|
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?;
|
||||||
|
Ok(club.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BalanceResponse {
|
||||||
|
pub balance: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/balance` — the club's currency balance.
|
||||||
|
pub async fn get_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::balance(&state.pool, &club).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
|
||||||
|
pub async fn get_entitlements(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<Vec<economy::Entitlement>>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::list_unopened_entitlements(&state.pool, &club).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseEntitlementRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
|
||||||
|
pub async fn post_purchase_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<economy::PurchaseReceipt>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RedeemEntitlementRequest {
|
||||||
|
pub entitlement_id: String,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RedeemEntitlementResponse {
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
|
||||||
|
pub async fn post_redeem_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<RedeemEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<RedeemEntitlementResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let definition_id =
|
||||||
|
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
|
||||||
|
Ok(Json(RedeemEntitlementResponse { definition_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SellItemRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub price: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/sell-item` — atomic remove + credit.
|
||||||
|
pub async fn post_sell_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<SellItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GrantRewardRequest {
|
||||||
|
pub amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/grant-reward` — atomic credit.
|
||||||
|
pub async fn post_grant_reward(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<GrantRewardRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-item` — atomic debit + mint item.
|
||||||
|
pub async fn post_purchase_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance =
|
||||||
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemsRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-items` — atomic debit + mint several items.
|
||||||
|
pub async fn post_purchase_items(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemsRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::event as event_svc,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
let events = event_svc::get_all_with_status(&state.pool, &state.event_defs).await?;
|
||||||
|
let active_count = events.iter().filter(|e| e.is_active).count();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"events": events,
|
||||||
|
"total": events.len(),
|
||||||
|
"active_count": active_count,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_event(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let def = state
|
||||||
|
.event_defs
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.id == event_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
|
||||||
|
|
||||||
|
let is_active = event_svc::is_event_active(&state.pool, def).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"event": def,
|
||||||
|
"is_active": is_active,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_activate_event(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
event_svc::activate_event(&state.pool, &event_id, &state.event_defs).await?;
|
||||||
|
Ok(Json(json!({ "activated": event_id, "is_active": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_deactivate_event(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(event_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
event_svc::deactivate_event(&state.pool, &event_id, &state.event_defs).await?;
|
||||||
|
Ok(Json(json!({ "deactivated": event_id, "is_active": false })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GET /fut-champs — current active session, or null if none.
|
||||||
|
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"session": session,
|
||||||
|
"max_matches": crate::models::fut_champs::MAX_MATCHES,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /fut-champs/start — open a new FUT Champions week.
|
||||||
|
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"session": session,
|
||||||
|
"message": "FUT Champions week started — play up to 30 matches",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChampsMatchRequest {
|
||||||
|
pub goals_for: i64,
|
||||||
|
pub goals_against: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /fut-champs/:session_id/result — record a match in this session.
|
||||||
|
pub async fn post_champs_result(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
Json(req): Json<ChampsMatchRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
|
||||||
|
let session = champs_svc::record_match(
|
||||||
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&session_id,
|
||||||
|
req.goals_for,
|
||||||
|
req.goals_against,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let outcome = if req.goals_for > req.goals_against {
|
||||||
|
"win"
|
||||||
|
} else if req.goals_for == req.goals_against {
|
||||||
|
"draw"
|
||||||
|
} else {
|
||||||
|
"loss"
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"session": session,
|
||||||
|
"outcome": outcome,
|
||||||
|
"matches_remaining": session.matches_remaining(),
|
||||||
|
"auto_completed": session.is_complete(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
||||||
|
pub async fn post_claim_champs_rewards(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
) -> 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 result = champs_svc::claim_rewards(
|
||||||
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&session_id,
|
||||||
|
&club.id,
|
||||||
|
&state.pack_defs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /fut-champs/history — past sessions, newest first.
|
||||||
|
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"history": history,
|
||||||
|
"total": history.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
||||||
|
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> 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?;
|
||||||
|
|
||||||
|
// Ensure a season row exists
|
||||||
|
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
|
let result = champs_svc::claim_rivals_reward(
|
||||||
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&state.pack_defs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
+37
-5
@@ -1,5 +1,6 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -12,6 +13,14 @@ use crate::{
|
|||||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> 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 trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||||
|
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MarketQuery {
|
pub struct MarketQuery {
|
||||||
pub min_overall: Option<u8>,
|
pub min_overall: Option<u8>,
|
||||||
@@ -44,9 +53,10 @@ pub async fn get_market(
|
|||||||
|
|
||||||
pub async fn post_market_buy(
|
pub async fn post_market_buy(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<BuyListingRequest>,
|
Json(req): Json<BuyListingRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -57,18 +67,40 @@ pub async fn post_market_buy(
|
|||||||
|
|
||||||
pub async fn post_market_sell(
|
pub async fn post_market_sell(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<SellCardRequest>,
|
Json(req): Json<SellCardRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let new_balance = market_svc::sell_card(&state.pool, &club.id, &req).await?;
|
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
|
json!({ "new_coin_balance": new_balance, "message": "Card sold to NPC market" }),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
let count = market_svc::refresh_npc_listings(&state.pool, &state.card_db).await?;
|
let count =
|
||||||
|
market_svc::refresh_npc_listings(&state.pool, &state.card_db, &state.event_defs).await?;
|
||||||
Ok(Json(json!({ "listings_generated": count })))
|
Ok(Json(json!({ "listings_generated": count })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return all active market listings posted by the current player's club.
|
||||||
|
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> 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 listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||||
|
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a player-posted listing and return the card to the collection.
|
||||||
|
pub async fn delete_market_listing(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(listing_id): Path<String>,
|
||||||
|
) -> 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?;
|
||||||
|
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
||||||
|
Ok(Json(json!({ "cancelled": listing_id })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -20,9 +21,10 @@ pub struct MatchHistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_matches(
|
pub async fn get_matches(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Query(query): Query<MatchHistoryQuery>,
|
Query(query): Query<MatchHistoryQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = if let Some(mode) = &query.mode {
|
let matches = if let Some(mode) = &query.mode {
|
||||||
@@ -63,13 +65,14 @@ pub async fn get_opponent(
|
|||||||
|
|
||||||
pub async fn post_match_result(
|
pub async fn post_match_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<SubmitMatchRequest>,
|
Json(req): Json<SubmitMatchRequest>,
|
||||||
) -> AppResult<Json<MatchRewardResult>> {
|
) -> AppResult<Json<MatchRewardResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs)
|
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
pub mod achievements;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod economy;
|
||||||
|
pub mod fut_champs;
|
||||||
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
|
pub mod notifications;
|
||||||
pub mod objectives;
|
pub mod objectives;
|
||||||
pub mod packs;
|
pub mod packs;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
@@ -12,3 +18,4 @@ pub mod sbc;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
pub mod upgrades;
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GET /notifications
|
||||||
|
///
|
||||||
|
/// Returns persistent (event-driven) notifications merged with dynamic state
|
||||||
|
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||||
|
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||||
|
/// have `id: null` and are always considered unread.
|
||||||
|
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> 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?;
|
||||||
|
|
||||||
|
// ── Persistent notifications ─────────────────────────────────────────────
|
||||||
|
let persistent = notif_svc::list(&state.pool).await?;
|
||||||
|
let persistent_unread = persistent.iter().filter(|n| !n.is_read).count() as i64;
|
||||||
|
|
||||||
|
// ── Dynamic: unclaimed objective rewards ─────────────────────────────────
|
||||||
|
let objectives =
|
||||||
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
|
let mut dynamic: Vec<Value> = objectives
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.completed && !o.claimed)
|
||||||
|
.map(|o| json!({
|
||||||
|
"id": null,
|
||||||
|
"type": "objective_complete",
|
||||||
|
"title": format!("Objective complete: {}", o.definition.title),
|
||||||
|
"body": format!("Claim your reward for \"{}\" (+{} coins)", o.definition.title, o.definition.reward_coins),
|
||||||
|
"is_read": false,
|
||||||
|
"created_at": null,
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// ── Dynamic: loan cards expiring within 2 matches ────────────────────────
|
||||||
|
let expiring: Vec<(String, String, Option<i64>)> = sqlx::query_as(
|
||||||
|
"SELECT oc.id, oc.card_id, oc.loan_matches_remaining \
|
||||||
|
FROM owned_cards oc WHERE oc.club_id = ? AND oc.is_loan = 1 \
|
||||||
|
AND oc.loan_matches_remaining IS NOT NULL AND oc.loan_matches_remaining <= 2",
|
||||||
|
)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (owned_id, card_id, remaining) in expiring {
|
||||||
|
let card_name = state
|
||||||
|
.card_db
|
||||||
|
.get(&card_id)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_else(|| card_id.clone());
|
||||||
|
dynamic.push(json!({
|
||||||
|
"id": null,
|
||||||
|
"type": "loan_expiring",
|
||||||
|
"title": format!("Loan expiring: {card_name}"),
|
||||||
|
"body": format!("{card_name} has {} match(es) remaining on loan.", remaining.unwrap_or(0)),
|
||||||
|
"is_read": false,
|
||||||
|
"created_at": null,
|
||||||
|
"owned_card_id": owned_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dynamic: season ending soon ──────────────────────────────────────────
|
||||||
|
let season_row: Option<(i64, i64)> =
|
||||||
|
sqlx::query_as("SELECT matches_played, division FROM seasons WHERE profile_id = ?")
|
||||||
|
.bind(&profile.id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((played, division)) = season_row {
|
||||||
|
let remaining = (10 - played).max(0);
|
||||||
|
if remaining <= 2 && remaining > 0 {
|
||||||
|
dynamic.push(json!({
|
||||||
|
"id": null,
|
||||||
|
"type": "season_ending",
|
||||||
|
"title": format!("Season ending — Division {division}"),
|
||||||
|
"body": format!("{remaining} match(es) left in the current season."),
|
||||||
|
"is_read": false,
|
||||||
|
"created_at": null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge: persistent first (newest first), then dynamic ─────────────────
|
||||||
|
// Use "type" key for compatibility with dashboard and existing tests.
|
||||||
|
let all: Vec<Value> = persistent
|
||||||
|
.iter()
|
||||||
|
.map(|n| json!({
|
||||||
|
"id": n.id,
|
||||||
|
"type": n.kind,
|
||||||
|
"title": n.title,
|
||||||
|
"body": n.body,
|
||||||
|
"is_read": n.is_read,
|
||||||
|
"created_at": n.created_at,
|
||||||
|
}))
|
||||||
|
.chain(dynamic.iter().cloned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_unread = persistent_unread + dynamic.len() as i64;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"notifications": all,
|
||||||
|
"unread_count": total_unread,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /notifications/:id/read
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let found = notif_svc::mark_read(&state.pool, &id).await?;
|
||||||
|
if !found {
|
||||||
|
return Err(AppError::NotFound(format!("notification '{id}' not found")));
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "marked_read": id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /notifications/read-all
|
||||||
|
pub async fn mark_all_notifications_read(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let count = notif_svc::mark_all_read(&state.pool).await?;
|
||||||
|
Ok(Json(json!({ "marked_read": count })))
|
||||||
|
}
|
||||||
@@ -1,20 +1,57 @@
|
|||||||
use axum::{extract::State, Json};
|
use crate::extractors::GameId;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
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},
|
||||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let objectives =
|
let objectives =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
Ok(Json(json!({ "objectives": objectives })))
|
Ok(Json(json!({ "objectives": objectives })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_objective(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(objective_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let all =
|
||||||
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
|
let obj = all
|
||||||
|
.into_iter()
|
||||||
|
.find(|o| o.definition.id == objective_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("objective '{objective_id}' not found")))?;
|
||||||
|
Ok(Json(json!({ "objective": obj })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_claim_objective_by_id(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(objective_id): Path<String>,
|
||||||
|
) -> 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 reward = obj_svc::claim_objective(
|
||||||
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&state.obj_defs,
|
||||||
|
&objective_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "claimed": true, "reward": reward })))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ClaimRequest {
|
pub struct ClaimRequest {
|
||||||
pub objective_id: String,
|
pub objective_id: String,
|
||||||
@@ -22,9 +59,10 @@ pub struct ClaimRequest {
|
|||||||
|
|
||||||
pub async fn post_claim_objective(
|
pub async fn post_claim_objective(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<ClaimRequest>,
|
Json(req): Json<ClaimRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
let reward = obj_svc::claim_objective(
|
let reward = obj_svc::claim_objective(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
+73
-4
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -19,9 +20,10 @@ pub struct BuyPackRequest {
|
|||||||
|
|
||||||
pub async fn post_buy_pack(
|
pub async fn post_buy_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<BuyPackRequest>,
|
Json(req): Json<BuyPackRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
let pack = pack_svc::buy_pack(
|
let pack = pack_svc::buy_pack(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -35,8 +37,27 @@ pub async fn post_buy_pack(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
/// List all purchasable pack definitions with their coin costs.
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
let store: Vec<Value> = state
|
||||||
|
.pack_defs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let total_cards: u8 = d.slots.iter().map(|s| s.count).sum();
|
||||||
|
json!({
|
||||||
|
"id": d.id,
|
||||||
|
"name": d.name,
|
||||||
|
"description": d.description,
|
||||||
|
"cost_coins": d.cost_coins,
|
||||||
|
"total_cards": total_cards,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "packs": store })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||||
|
|
||||||
@@ -57,11 +78,56 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
|
|||||||
Ok(Json(json!({ "packs": with_defs })))
|
Ok(Json(json!({ "packs": with_defs })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return recently opened packs with the card IDs they contained.
|
||||||
|
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> 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 opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||||
|
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at \
|
||||||
|
FROM packs WHERE club_id = ? AND opened = 1 ORDER BY opened_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
.bind(&club.id)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let history: Vec<Value> = opened
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let def = state.pack_defs.iter().find(|d| d.id == p.definition_id);
|
||||||
|
// Expand card_ids into full card definitions
|
||||||
|
let cards: Vec<Value> = p
|
||||||
|
.opened_cards
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.map(|id| {
|
||||||
|
json!({
|
||||||
|
"card_id": id,
|
||||||
|
"card": state.card_db.get(id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"pack_id": p.id,
|
||||||
|
"definition_id": p.definition_id,
|
||||||
|
"name": def.map(|d| &d.name),
|
||||||
|
"opened_at": p.opened_at,
|
||||||
|
"cards": cards,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_open_pack(
|
pub async fn post_open_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Path(pack_id): Path<String>,
|
Path(pack_id): Path<String>,
|
||||||
) -> AppResult<Json<PackOpenResult>> {
|
) -> AppResult<Json<PackOpenResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let result = pack_svc::open_pack(
|
let result = pack_svc::open_pack(
|
||||||
@@ -76,6 +142,9 @@ pub async fn post_open_pack(
|
|||||||
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
|
statistics::increment_packs_opened(&state.pool, &profile.id).await?;
|
||||||
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||||
.await?;
|
.await?;
|
||||||
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
|
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
||||||
|
).await;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-4
@@ -1,9 +1,35 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState, error::AppResult, models::profile::Profile, services::profile as profile_svc,
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
models::profile::{level_for_xp, XP_THRESHOLDS},
|
||||||
|
services::profile as profile_svc,
|
||||||
};
|
};
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Profile>> {
|
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
Ok(Json(profile))
|
let computed_level = level_for_xp(profile.xp);
|
||||||
|
|
||||||
|
let next_level = computed_level + 1;
|
||||||
|
let xp_for_next = if next_level as usize <= XP_THRESHOLDS.len() {
|
||||||
|
XP_THRESHOLDS[next_level as usize - 1]
|
||||||
|
} else {
|
||||||
|
let overflow_base = XP_THRESHOLDS.last().copied().unwrap_or(0);
|
||||||
|
let levels_past = next_level - XP_THRESHOLDS.len() as i64;
|
||||||
|
overflow_base + levels_past * 2500
|
||||||
|
};
|
||||||
|
let xp_to_next = (xp_for_next - profile.xp).max(0);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": profile.id,
|
||||||
|
"username": profile.username,
|
||||||
|
"level": computed_level,
|
||||||
|
"xp": profile.xp,
|
||||||
|
"xp_to_next_level": xp_to_next,
|
||||||
|
"xp_for_next_level": xp_for_next,
|
||||||
|
"created_at": profile.created_at,
|
||||||
|
"updated_at": profile.updated_at,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -29,9 +30,10 @@ pub async fn get_sbc(
|
|||||||
|
|
||||||
pub async fn post_sbc_submit(
|
pub async fn post_sbc_submit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<SubmitSbcRequest>,
|
Json(req): Json<SubmitSbcRequest>,
|
||||||
) -> AppResult<Json<SbcResult>> {
|
) -> AppResult<Json<SbcResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let result = sbc_svc::submit_sbc(
|
let result = sbc_svc::submit_sbc(
|
||||||
@@ -45,5 +47,11 @@ pub async fn post_sbc_submit(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if result.passed {
|
||||||
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
|
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-13
@@ -1,18 +1,25 @@
|
|||||||
|
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>) -> AppResult<Json<Value>> {
|
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||||
@@ -21,8 +28,8 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
|
|||||||
Ok(Json(squad_response(&squad, &players, chemistry)))
|
Ok(Json(squad_response(&squad, &players, chemistry)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "squads": squads })))
|
Ok(Json(json!({ "squads": squads })))
|
||||||
@@ -30,9 +37,10 @@ pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>>
|
|||||||
|
|
||||||
pub async fn get_squad_by_id(
|
pub async fn get_squad_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
||||||
@@ -43,24 +51,26 @@ pub async fn get_squad_by_id(
|
|||||||
|
|
||||||
pub async fn post_squad(
|
pub async fn post_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Json(req): Json<SaveSquadRequest>,
|
Json(req): Json<SaveSquadRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
|
|
||||||
if !req.players.is_empty() {
|
if !req.players.is_empty() {
|
||||||
squad_svc::validate_formation(&state.pool, &state.card_db, &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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_squad(
|
pub async fn delete_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).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?;
|
||||||
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
||||||
Ok(Json(json!({ "deleted": squad_id })))
|
Ok(Json(json!({ "deleted": squad_id })))
|
||||||
@@ -94,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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -12,8 +13,8 @@ use crate::{
|
|||||||
services::{profile as profile_svc, statistics as stats_svc},
|
services::{profile as profile_svc, statistics as stats_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -36,9 +37,10 @@ pub struct HistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_statistics_history(
|
pub async fn get_statistics_history(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
Query(query): Query<HistoryQuery>,
|
Query(query): Query<HistoryQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = sqlx::query_as::<_, Match>(
|
let matches = sqlx::query_as::<_, Match>(
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use crate::extractors::GameId;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
services::{club as club_svc, profile as profile_svc, upgrades as upgrade_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GET /chemistry-styles — list all available styles with their stat boosts.
|
||||||
|
pub async fn get_chemistry_styles(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
|
Ok(Json(json!({
|
||||||
|
"chemistry_styles": *state.chem_styles,
|
||||||
|
"total": state.chem_styles.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyChemStyleRequest {
|
||||||
|
pub style_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/chemistry-style
|
||||||
|
pub async fn post_apply_chemistry_style(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ApplyChemStyleRequest>,
|
||||||
|
) -> 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 updated = upgrade_svc::apply_chemistry_style(
|
||||||
|
&state.pool,
|
||||||
|
&club.id,
|
||||||
|
&owned_card_id,
|
||||||
|
&req.style_id,
|
||||||
|
&state.chem_styles,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let style = state
|
||||||
|
.chem_styles
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == updated.chemistry_style);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"chemistry_style": updated.chemistry_style,
|
||||||
|
"style_details": style,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChangePositionRequest {
|
||||||
|
pub position: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||||
|
pub async fn post_change_position(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ChangePositionRequest>,
|
||||||
|
) -> 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 updated = upgrade_svc::change_position(
|
||||||
|
&state.pool,
|
||||||
|
&club.id,
|
||||||
|
&owned_card_id,
|
||||||
|
&req.position,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"original_position": card_def.map(|c| &c.position),
|
||||||
|
"position_override": updated.position_override,
|
||||||
|
"cost_coins": upgrade_svc::POSITION_CHANGE_COST,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyTrainingRequest {
|
||||||
|
/// OVR points to add. Must be 1–3. Total capped at +3.
|
||||||
|
pub boost: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||||
|
pub async fn post_apply_training(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(owned_card_id): Path<String>,
|
||||||
|
Json(req): Json<ApplyTrainingRequest>,
|
||||||
|
) -> 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 updated =
|
||||||
|
upgrade_svc::apply_training(&state.pool, &club.id, &owned_card_id, req.boost).await?;
|
||||||
|
|
||||||
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
let base_overall = card_def.map(|c| c.overall as i64).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"owned_card_id": updated.id,
|
||||||
|
"card_id": updated.card_id,
|
||||||
|
"base_overall": base_overall,
|
||||||
|
"training_bonus": updated.training_bonus,
|
||||||
|
"effective_overall": base_overall + updated.training_bonus,
|
||||||
|
"max_bonus": upgrade_svc::MAX_TRAINING_BONUS,
|
||||||
|
})))
|
||||||
|
}
|
||||||
+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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::AppResult,
|
||||||
|
models::achievement::{AchievementDefinition, PlayerAchievement},
|
||||||
|
services::{club as club_svc, notification},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use std::path::Path;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<AchievementDefinition>> {
|
||||||
|
let dir = Path::new(data_dir).join("achievements");
|
||||||
|
let mut defs = Vec::new();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(defs);
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(&dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||||
|
let batch: Vec<AchievementDefinition> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||||
|
defs.extend(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the current value for the given trigger metric.
|
||||||
|
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
|
||||||
|
let v: i64 = match trigger {
|
||||||
|
"matches_played" => sqlx::query_scalar(
|
||||||
|
"SELECT matches_played FROM statistics WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0),
|
||||||
|
|
||||||
|
"matches_won" => sqlx::query_scalar(
|
||||||
|
"SELECT matches_won FROM statistics WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0),
|
||||||
|
|
||||||
|
"goals_scored" => sqlx::query_scalar(
|
||||||
|
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0),
|
||||||
|
|
||||||
|
"packs_opened" => sqlx::query_scalar(
|
||||||
|
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0),
|
||||||
|
|
||||||
|
"sbcs_completed" => sqlx::query_scalar(
|
||||||
|
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0),
|
||||||
|
|
||||||
|
"cards_owned" => sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?,
|
||||||
|
|
||||||
|
"level" => sqlx::query_scalar(
|
||||||
|
"SELECT level FROM profiles WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(1),
|
||||||
|
|
||||||
|
"objectives_completed" => sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?,
|
||||||
|
|
||||||
|
"drafts_completed" => sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?,
|
||||||
|
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check all achievement definitions and unlock any not yet earned.
|
||||||
|
/// Returns definitions of newly unlocked achievements.
|
||||||
|
pub async fn check_and_unlock(
|
||||||
|
pool: &Pool,
|
||||||
|
defs: &[AchievementDefinition],
|
||||||
|
profile_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
) -> AppResult<Vec<AchievementDefinition>> {
|
||||||
|
if defs.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load already-unlocked IDs in one query
|
||||||
|
let unlocked_ids: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let unlocked_set: std::collections::HashSet<&str> =
|
||||||
|
unlocked_ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let candidates: Vec<&AchievementDefinition> = defs
|
||||||
|
.iter()
|
||||||
|
.filter(|d| !unlocked_set.contains(d.id.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group candidates by trigger to minimise DB round-trips
|
||||||
|
let mut trigger_cache: std::collections::HashMap<String, i64> = Default::default();
|
||||||
|
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
for def in candidates {
|
||||||
|
let value = match trigger_cache.get(&def.trigger) {
|
||||||
|
Some(&v) => v,
|
||||||
|
None => {
|
||||||
|
let v = metric_value(pool, profile_id, club_id, &def.trigger).await?;
|
||||||
|
trigger_cache.insert(def.trigger.clone(), v);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if value >= def.threshold {
|
||||||
|
let pa_id = Uuid::new_v4().to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&pa_id)
|
||||||
|
.bind(&def.id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if def.reward_coins > 0 {
|
||||||
|
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"{} Reward: {} coins.",
|
||||||
|
def.description, def.reward_coins
|
||||||
|
);
|
||||||
|
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
|
||||||
|
|
||||||
|
newly_unlocked.push(def.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(newly_unlocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return all achievement definitions annotated with unlock status.
|
||||||
|
pub async fn list_with_status(
|
||||||
|
pool: &Pool,
|
||||||
|
defs: &[AchievementDefinition],
|
||||||
|
) -> AppResult<Vec<serde_json::Value>> {
|
||||||
|
let earned: Vec<PlayerAchievement> = sqlx::query_as::<_, PlayerAchievement>(
|
||||||
|
"SELECT id, achievement_id, unlocked_at FROM player_achievements",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let earned_map: std::collections::HashMap<&str, &str> = earned
|
||||||
|
.iter()
|
||||||
|
.map(|pa| (pa.achievement_id.as_str(), pa.unlocked_at.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(defs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
let unlocked_at = earned_map.get(d.id.as_str()).copied();
|
||||||
|
serde_json::json!({
|
||||||
|
"id": d.id,
|
||||||
|
"title": d.title,
|
||||||
|
"description": d.description,
|
||||||
|
"icon": d.icon,
|
||||||
|
"trigger": d.trigger,
|
||||||
|
"threshold": d.threshold,
|
||||||
|
"reward_coins": d.reward_coins,
|
||||||
|
"rarity": d.rarity,
|
||||||
|
"unlocked": unlocked_at.is_some(),
|
||||||
|
"unlocked_at": unlocked_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
+41
-11
@@ -38,19 +38,49 @@ impl CardDb {
|
|||||||
Ok(Self { cards })
|
Ok(Self { cards })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
/// Merge a game's **opt-in development content pack** from
|
||||||
self.cards.get(id)
|
/// `{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)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Merge an explicit PRODUCTION content pack file (a single
|
||||||
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
/// `CardDefinition[]`). Unlike [`CardDb::load_game_dev`] this takes a direct
|
||||||
self.cards
|
/// path (the real-profile import emits one) and is the production content
|
||||||
.values()
|
/// path — not gated behind dev content. Returns the number merged.
|
||||||
.filter(|c| {
|
pub fn load_pack(&mut self, path: &Path) -> Result<usize> {
|
||||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
let content = std::fs::read_to_string(path)
|
||||||
r == rarity || rarity == "any"
|
.with_context(|| format!("reading content pack {path:?}"))?;
|
||||||
})
|
let batch: Vec<CardDefinition> =
|
||||||
.collect()
|
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 {} production card definitions from {:?}", n, path);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||||
|
self.cards.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use crate::{db::Pool, error::AppResult, services::{club, pack}};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
||||||
|
// Day 7 (index 6) also grants a pack:
|
||||||
|
const STREAK_7_PACK: &str = "silver_pack";
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct CheckinStatus {
|
||||||
|
pub available: bool,
|
||||||
|
pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in)
|
||||||
|
pub next_reward_coins: i64,
|
||||||
|
pub next_reward_pack: Option<&'static str>,
|
||||||
|
pub last_checked_in: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct CheckinResult {
|
||||||
|
pub coins_awarded: i64,
|
||||||
|
pub pack_awarded: Option<String>,
|
||||||
|
pub new_streak: i64,
|
||||||
|
pub already_claimed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatus> {
|
||||||
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
||||||
|
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let today = today_date();
|
||||||
|
match row {
|
||||||
|
None => Ok(CheckinStatus {
|
||||||
|
available: true,
|
||||||
|
streak_day: 0,
|
||||||
|
next_reward_coins: STREAK_COINS[0],
|
||||||
|
next_reward_pack: None,
|
||||||
|
last_checked_in: None,
|
||||||
|
}),
|
||||||
|
Some((last_streak, last_at)) => {
|
||||||
|
let last_day = &last_at[..10]; // YYYY-MM-DD
|
||||||
|
let available = last_day != today.as_str();
|
||||||
|
let next_streak = compute_next_streak(last_streak, &last_at);
|
||||||
|
let idx = ((next_streak - 1) % 7) as usize;
|
||||||
|
Ok(CheckinStatus {
|
||||||
|
available,
|
||||||
|
streak_day: if available { next_streak } else { last_streak },
|
||||||
|
next_reward_coins: STREAK_COINS[idx],
|
||||||
|
next_reward_pack: if idx == 6 { Some(STREAK_7_PACK) } else { None },
|
||||||
|
last_checked_in: Some(last_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn claim(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
) -> AppResult<CheckinResult> {
|
||||||
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
||||||
|
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let today = today_date();
|
||||||
|
if let Some((_, ref last_at)) = row {
|
||||||
|
if &last_at[..10] == today.as_str() {
|
||||||
|
let last_streak = row.as_ref().map(|(s, _)| *s).unwrap_or(1);
|
||||||
|
return Ok(CheckinResult {
|
||||||
|
coins_awarded: 0,
|
||||||
|
pack_awarded: None,
|
||||||
|
new_streak: last_streak,
|
||||||
|
already_claimed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
|
||||||
|
let idx = ((last_streak - 1) % 7) as usize;
|
||||||
|
let coins = STREAK_COINS[idx];
|
||||||
|
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
||||||
|
|
||||||
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
|
if let Some(def) = pack_def {
|
||||||
|
let _ = pack::grant_pack(pool, club_id, def).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(last_streak)
|
||||||
|
.bind(coins)
|
||||||
|
.bind(pack_def)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(CheckinResult {
|
||||||
|
coins_awarded: coins,
|
||||||
|
pack_awarded: pack_def.map(String::from),
|
||||||
|
new_streak: last_streak,
|
||||||
|
already_claimed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today_date() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If last check-in was yesterday or today → continue streak; otherwise reset to 1.
|
||||||
|
fn compute_next_streak(last_streak: i64, last_at: &str) -> i64 {
|
||||||
|
let last_day = &last_at[..10];
|
||||||
|
let today = chrono::Utc::now().date_naive();
|
||||||
|
let yesterday = (today - chrono::Days::new(1)).to_string();
|
||||||
|
let today_str = today.to_string();
|
||||||
|
if last_day == yesterday || last_day == today_str {
|
||||||
|
(last_streak % 7) + 1
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-8
@@ -5,19 +5,30 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
|
const CLUB_SELECT: &str =
|
||||||
|
"SELECT id, profile_id, name, coins, level, created_at, updated_at, manager_name \
|
||||||
|
FROM clubs";
|
||||||
|
|
||||||
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
pub async fn get_club_by_profile(pool: &Pool, profile_id: &str) -> AppResult<Club> {
|
||||||
sqlx::query_as::<_, Club>(
|
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE profile_id = ? LIMIT 1"))
|
||||||
"SELECT id, profile_id, name, coins, level, created_at, updated_at FROM clubs WHERE profile_id = ? LIMIT 1"
|
.bind(profile_id)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(profile_id)
|
.await?
|
||||||
.fetch_optional(pool)
|
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||||
.await?
|
}
|
||||||
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
|
||||||
|
pub async fn get_club_by_id(pool: &Pool, club_id: &str) -> AppResult<Club> {
|
||||||
|
sqlx::query_as::<_, Club>(&format!("{CLUB_SELECT} WHERE id = ? LIMIT 1"))
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("club not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at, manager_name) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.bind(&club.profile_id)
|
.bind(&club.profile_id)
|
||||||
@@ -26,11 +37,38 @@ pub async fn create_club(pool: &Pool, club: &Club) -> AppResult<()> {
|
|||||||
.bind(club.level)
|
.bind(club.level)
|
||||||
.bind(club.created_at)
|
.bind(club.created_at)
|
||||||
.bind(club.updated_at)
|
.bind(club.updated_at)
|
||||||
|
.bind(&club.manager_name)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_club(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
manager_name: Option<&str>,
|
||||||
|
) -> AppResult<Club> {
|
||||||
|
let now = Utc::now();
|
||||||
|
if let Some(n) = name {
|
||||||
|
sqlx::query("UPDATE clubs SET name = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(n)
|
||||||
|
.bind(now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(m) = manager_name {
|
||||||
|
sqlx::query("UPDATE clubs SET manager_name = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(m)
|
||||||
|
.bind(now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
get_club_by_id(pool, club_id).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use rand::seq::SliceRandom;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::draft::{DraftSession, DRAFT_PICK_ORDER},
|
||||||
|
services::{card_db::CardDb, pack},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CANDIDATES_PER_SLOT: usize = 5;
|
||||||
|
|
||||||
|
fn difficulty_min_overall(difficulty: &str) -> u8 {
|
||||||
|
match difficulty {
|
||||||
|
"world_class" => 78,
|
||||||
|
"legendary" => 83,
|
||||||
|
"ultimate" => 88,
|
||||||
|
"professional" => 70,
|
||||||
|
_ => 55,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick up to N candidates for a given position slot.
|
||||||
|
/// Tries exact position match first, fills remainder from the general pool.
|
||||||
|
fn pick_candidates(card_db: &CardDb, position: &str, min_overall: u8, n: usize) -> Vec<String> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let all = card_db.by_min_overall(min_overall);
|
||||||
|
|
||||||
|
// CDM / CAM → also accept CM as fallback
|
||||||
|
let pos_match = |p: &str| -> bool {
|
||||||
|
p.eq_ignore_ascii_case(position)
|
||||||
|
|| matches!(
|
||||||
|
(position.to_uppercase().as_str(), p.to_uppercase().as_str()),
|
||||||
|
("CDM", "CM") | ("CAM", "CM") | ("CM", "CDM") | ("CM", "CAM")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut exact: Vec<_> = all.iter().filter(|c| pos_match(&c.position)).collect();
|
||||||
|
exact.shuffle(&mut rng);
|
||||||
|
|
||||||
|
let mut pool: Vec<_> = all.iter().filter(|c| !pos_match(&c.position)).collect();
|
||||||
|
pool.shuffle(&mut rng);
|
||||||
|
|
||||||
|
exact
|
||||||
|
.into_iter()
|
||||||
|
.chain(pool)
|
||||||
|
.take(n)
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute reward tier based on average overall of picked cards.
|
||||||
|
fn compute_reward(card_db: &CardDb, picks: &[String]) -> (i64, Option<String>, i64) {
|
||||||
|
if picks.is_empty() {
|
||||||
|
return (0, None, 0);
|
||||||
|
}
|
||||||
|
let overalls: Vec<i64> = picks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| card_db.get(id).map(|c| c.overall as i64))
|
||||||
|
.collect();
|
||||||
|
let avg = if overalls.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
overalls.iter().sum::<i64>() / overalls.len() as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
let (coins, pack_id) = if avg >= 84 {
|
||||||
|
(2000, Some("gold_pack".to_string()))
|
||||||
|
} else if avg >= 78 {
|
||||||
|
(1000, Some("silver_pack".to_string()))
|
||||||
|
} else {
|
||||||
|
(400, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
(coins, pack_id, avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_draft(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
profile_id: &str,
|
||||||
|
difficulty: &str,
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
let min_overall = difficulty_min_overall(difficulty);
|
||||||
|
|
||||||
|
let pick_order: Vec<String> = DRAFT_PICK_ORDER.iter().map(|s| s.to_string()).collect();
|
||||||
|
let first_position = &pick_order[0];
|
||||||
|
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
||||||
|
|
||||||
|
let pick_order_json = serde_json::to_string(&pick_order)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||||
|
let candidates_json = serde_json::to_string(&candidates)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||||
|
|
||||||
|
let session = DraftSession {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
profile_id: profile_id.to_string(),
|
||||||
|
difficulty: difficulty.to_string(),
|
||||||
|
pick_order: pick_order_json,
|
||||||
|
picks: "[]".to_string(),
|
||||||
|
current_candidates: Some(candidates_json),
|
||||||
|
status: "active".to_string(),
|
||||||
|
reward_coins: 0,
|
||||||
|
reward_pack_id: None,
|
||||||
|
squad_rating: 0,
|
||||||
|
started_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
completed_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO draft_sessions (id, profile_id, difficulty, pick_order, picks, \
|
||||||
|
current_candidates, status, reward_coins, reward_pack_id, squad_rating, \
|
||||||
|
started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&session.id)
|
||||||
|
.bind(&session.profile_id)
|
||||||
|
.bind(&session.difficulty)
|
||||||
|
.bind(&session.pick_order)
|
||||||
|
.bind(&session.picks)
|
||||||
|
.bind(&session.current_candidates)
|
||||||
|
.bind(&session.status)
|
||||||
|
.bind(session.reward_coins)
|
||||||
|
.bind(&session.reward_pack_id)
|
||||||
|
.bind(session.squad_rating)
|
||||||
|
.bind(&session.started_at)
|
||||||
|
.bind(&session.completed_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(render_session(&session, card_db))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_draft(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
profile_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
let session = fetch_session(pool, profile_id, session_id).await?;
|
||||||
|
Ok(render_session(&session, card_db))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pick_card(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
club_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
card_id: &str,
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
let mut session = fetch_session(pool, profile_id, session_id).await?;
|
||||||
|
|
||||||
|
if session.status != "active" {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"draft session is '{}'",
|
||||||
|
session.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the picked card is in the current candidates list
|
||||||
|
let candidates: Vec<String> = session
|
||||||
|
.current_candidates
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !candidates.contains(&card_id.to_string()) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"card_id is not among the current candidates".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad pick_order json: {e}")))?;
|
||||||
|
let mut picks: Vec<String> = serde_json::from_str(&session.picks)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("bad picks json: {e}")))?;
|
||||||
|
|
||||||
|
picks.push(card_id.to_string());
|
||||||
|
|
||||||
|
let next_index = picks.len();
|
||||||
|
let all_filled = next_index >= pick_order.len();
|
||||||
|
|
||||||
|
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
||||||
|
if all_filled {
|
||||||
|
let (coins, pack, avg) = compute_reward(card_db, &picks);
|
||||||
|
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
|
||||||
|
} else {
|
||||||
|
let min_overall = difficulty_min_overall(&session.difficulty);
|
||||||
|
let next_pos = &pick_order[next_index];
|
||||||
|
let next_candidates =
|
||||||
|
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||||
|
{
|
||||||
|
let candidates_json = serde_json::to_string(&next_candidates)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||||
|
(
|
||||||
|
Some(candidates_json),
|
||||||
|
"active".to_string(),
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let picks_json = serde_json::to_string(&picks)
|
||||||
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
||||||
|
reward_coins = ?, reward_pack_id = ?, squad_rating = ?, completed_at = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&picks_json)
|
||||||
|
.bind(&new_candidates)
|
||||||
|
.bind(&new_status)
|
||||||
|
.bind(reward_coins)
|
||||||
|
.bind(&reward_pack_id)
|
||||||
|
.bind(squad_rating)
|
||||||
|
.bind(&completed_at)
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
session.picks = picks_json;
|
||||||
|
session.current_candidates = new_candidates;
|
||||||
|
session.status = new_status.clone();
|
||||||
|
session.reward_coins = reward_coins;
|
||||||
|
session.reward_pack_id = reward_pack_id.clone();
|
||||||
|
session.squad_rating = squad_rating;
|
||||||
|
session.completed_at = completed_at;
|
||||||
|
|
||||||
|
// Grant reward when draft is completed
|
||||||
|
if new_status == "completed" {
|
||||||
|
if reward_coins > 0 {
|
||||||
|
crate::services::club::add_coins(pool, club_id, reward_coins).await?;
|
||||||
|
}
|
||||||
|
if let Some(ref pack_def_id) = reward_pack_id {
|
||||||
|
pack::grant_pack(pool, club_id, pack_def_id).await?;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
session_id,
|
||||||
|
profile_id,
|
||||||
|
reward_coins,
|
||||||
|
squad_rating,
|
||||||
|
"draft completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rendered = render_session(&session, card_db);
|
||||||
|
rendered["just_completed"] = json!(new_status == "completed");
|
||||||
|
if new_status == "completed" {
|
||||||
|
rendered["reward"] = json!({
|
||||||
|
"coins": reward_coins,
|
||||||
|
"pack_id": reward_pack_id,
|
||||||
|
"squad_rating": squad_rating,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn abandon_draft(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
let session = fetch_session(pool, profile_id, session_id).await?;
|
||||||
|
|
||||||
|
if session.status == "completed" {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"cannot abandon a completed draft".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE draft_sessions SET status = 'abandoned' WHERE id = ?")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(json!({ "abandoned": session_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppResult<DraftSession> {
|
||||||
|
sqlx::query_as::<_, DraftSession>(
|
||||||
|
"SELECT id, profile_id, difficulty, pick_order, picks, current_candidates, \
|
||||||
|
status, reward_coins, reward_pack_id, squad_rating, started_at, completed_at \
|
||||||
|
FROM draft_sessions WHERE id = ? AND profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("draft session '{session_id}' not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
|
||||||
|
let pick_order: Vec<String> =
|
||||||
|
serde_json::from_str(&session.pick_order).unwrap_or_default();
|
||||||
|
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
||||||
|
let candidates: Vec<String> = session
|
||||||
|
.current_candidates
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let picks_with_defs: Vec<serde_json::Value> = picks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, card_id)| {
|
||||||
|
json!({
|
||||||
|
"slot": i,
|
||||||
|
"position": pick_order.get(i).cloned().unwrap_or_default(),
|
||||||
|
"card_id": card_id,
|
||||||
|
"card": card_db.get(card_id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let current_position = pick_order.get(picks.len()).cloned();
|
||||||
|
let candidates_with_defs: Vec<serde_json::Value> = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|id| json!({ "card_id": id, "card": card_db.get(id) }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"session_id": session.id,
|
||||||
|
"difficulty": session.difficulty,
|
||||||
|
"status": session.status,
|
||||||
|
"progress": { "filled": picks.len(), "total": pick_order.len() },
|
||||||
|
"current_position": current_position,
|
||||||
|
"candidates": candidates_with_defs,
|
||||||
|
"picks": picks_with_defs,
|
||||||
|
"squad_rating": session.squad_rating,
|
||||||
|
"started_at": session.started_at,
|
||||||
|
"completed_at": session.completed_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
//! Generic, game-agnostic economy authority.
|
||||||
|
//!
|
||||||
|
//! Exposes atomic, fail-closed economy operations over Core's existing durable
|
||||||
|
//! tables — it does **not** introduce a parallel persistence stack:
|
||||||
|
//!
|
||||||
|
//! * currency ledger -> `clubs.coins`
|
||||||
|
//! * owned inventory -> `owned_cards`
|
||||||
|
//! * entitlements -> `packs` (opaque `definition_id` + consume-once `opened`)
|
||||||
|
//!
|
||||||
|
//! Every compound operation (purchase, redeem, sell) runs inside a single SQLite
|
||||||
|
//! transaction, so a partial failure leaves no balance or inventory drift — the
|
||||||
|
//! pool-scoped helpers in [`crate::services::club`] cannot offer that guarantee
|
||||||
|
//! because their read/modify/write spans multiple pool round-trips.
|
||||||
|
//!
|
||||||
|
//! This module is deliberately game-neutral: currency names, entitlement/pack
|
||||||
|
//! ids, and per-save item-id sequences are per-game concerns that live in the
|
||||||
|
//! adapter which drives these primitives, never here.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// An instance to place into a club's inventory when an entitlement is redeemed.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GrantedItem {
|
||||||
|
/// Caller-minted opaque instance id. The adapter owns the id scheme; Core
|
||||||
|
/// treats it as an opaque unique key.
|
||||||
|
pub item_id: String,
|
||||||
|
/// Definition reference this instance resolves against.
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a purchase: the post-debit balance and the new entitlement id.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PurchaseReceipt {
|
||||||
|
pub balance: i64,
|
||||||
|
pub entitlement_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- transaction-scoped primitives -------------------------------------------
|
||||||
|
// Each takes a live connection (a transaction, reborrowed) so callers can compose
|
||||||
|
// several into one atomic unit. They never commit; the composed public op does.
|
||||||
|
|
||||||
|
async fn read_balance(conn: &mut SqliteConnection, club_id: &str) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn debit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"debit amount must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let balance = read_balance(conn, club_id).await?;
|
||||||
|
if balance < amount {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"insufficient balance: have {balance}, need {amount}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(amount)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(balance - amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn credit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"credit amount must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let balance = read_balance(conn, club_id).await?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(amount)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(balance + amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_entitlement(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
definition_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
// Ensure the club exists so we never orphan an entitlement.
|
||||||
|
read_balance(conn, club_id).await?;
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an unopened entitlement exactly once, returning its definition ref.
|
||||||
|
async fn consume_entitlement(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
entitlement_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let row = sqlx::query_as::<_, (String, i64)>(
|
||||||
|
"SELECT definition_id, opened FROM packs WHERE id = ? AND club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(entitlement_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
let (definition_id, opened) =
|
||||||
|
row.ok_or_else(|| AppError::NotFound(format!("entitlement not found: {entitlement_id}")))?;
|
||||||
|
if opened != 0 {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"entitlement already consumed: {entitlement_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(entitlement_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(definition_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_item(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
card_id: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(card_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_item(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let card_id = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
|
||||||
|
sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(card_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- composed atomic operations ----------------------------------------------
|
||||||
|
|
||||||
|
/// Read a club's current currency balance.
|
||||||
|
pub async fn balance(pool: &Pool, club_id: &str) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unopened entitlement a club owns.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Entitlement {
|
||||||
|
pub id: String,
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List a club's unconsumed entitlements (opened = 0), oldest first.
|
||||||
|
pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult<Vec<Entitlement>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT id, definition_id FROM packs WHERE club_id = ? AND opened = 0 ORDER BY created_at ASC, id ASC",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, definition_id)| Entitlement { id, definition_id })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit on `Ok`, roll back on `Err`. Paired with a `BEGIN IMMEDIATE` opened on
|
||||||
|
/// the same connection, so the write lock is held for the whole op and a
|
||||||
|
/// concurrent writer waits (honoring `busy_timeout`) instead of failing: a
|
||||||
|
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||||
|
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||||
|
/// deadlock) — the fresh-DB multi-connection write failure.
|
||||||
|
async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
|
||||||
|
match result {
|
||||||
|
Ok(v) => {
|
||||||
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
|
||||||
|
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
||||||
|
pub async fn purchase_entitlement(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
definition_id: &str,
|
||||||
|
) -> AppResult<PurchaseReceipt> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
let entitlement_id = grant_entitlement(&mut conn, club_id, definition_id).await?;
|
||||||
|
Ok(PurchaseReceipt {
|
||||||
|
balance,
|
||||||
|
entitlement_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club
|
||||||
|
/// cannot afford `cost`, nothing is debited and no item is added. This is the
|
||||||
|
/// "buy a specific item" primitive (a debit paired with an inventory add), for
|
||||||
|
/// synthetic-seller markets where the purchased item is minted rather than
|
||||||
|
/// transferred from another owner. Returns the post-debit balance.
|
||||||
|
pub async fn purchase_item(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
item_id: &str,
|
||||||
|
card_id: &str,
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
add_item(&mut conn, club_id, item_id, card_id).await?;
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and mint several owned items, atomically. Fail-closed: if the
|
||||||
|
/// club cannot afford `cost`, nothing is debited and no items are added; if any
|
||||||
|
/// item insert fails the whole purchase rolls back. This is the "buy + open"
|
||||||
|
/// primitive (Store packs that open on purchase): one debit paired with the
|
||||||
|
/// minted pack contents. Returns the post-debit balance.
|
||||||
|
pub async fn purchase_items(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
items: &[GrantedItem],
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
for item in items {
|
||||||
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
|
}
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an entitlement once and add its granted items, atomically. If any
|
||||||
|
/// item insert fails (e.g. a colliding instance id) the whole redemption rolls
|
||||||
|
/// back — the entitlement stays unconsumed and no items are persisted.
|
||||||
|
pub async fn redeem_entitlement(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
entitlement_id: &str,
|
||||||
|
items: &[GrantedItem],
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let definition_id = consume_entitlement(&mut conn, club_id, entitlement_id).await?;
|
||||||
|
for item in items {
|
||||||
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
|
}
|
||||||
|
Ok(definition_id)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
|
||||||
|
/// is not owned by the club nothing is credited.
|
||||||
|
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
remove_item(&mut conn, club_id, item_id).await?;
|
||||||
|
credit(&mut conn, club_id, price).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credit a reward to a club's balance atomically.
|
||||||
|
pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async { credit(&mut conn, club_id, amount).await }.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TS: &str = "2026-01-01T00:00:00Z";
|
||||||
|
|
||||||
|
/// In-memory pool with the real schema and one club (1000 coins) owning one
|
||||||
|
/// item (`item-x`). Mirrors the `squad` service test harness.
|
||||||
|
async fn fixture() -> Pool {
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind("prof")
|
||||||
|
.bind("prof")
|
||||||
|
.bind(TS)
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("profile");
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind("club")
|
||||||
|
.bind("prof")
|
||||||
|
.bind("club")
|
||||||
|
.bind(1000i64)
|
||||||
|
.bind(TS)
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("club");
|
||||||
|
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||||
|
.bind("item-x")
|
||||||
|
.bind("club")
|
||||||
|
.bind("def-x")
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("owned card");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pack_count(pool: &Pool) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM packs")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn item_count(pool: &Pool, item_id: &str) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn balance_reads_seeded_value() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert!(matches!(
|
||||||
|
balance(&pool, "ghost").await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_debits_and_grants() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let receipt = purchase_entitlement(&pool, "club", 300, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(receipt.balance, 700);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 700);
|
||||||
|
assert_eq!(pack_count(&pool).await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let err = purchase_entitlement(&pool, "club", 5000, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
// Nothing debited, no entitlement created.
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(pack_count(&pool).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn negative_amount_is_rejected() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert!(matches!(
|
||||||
|
purchase_entitlement(&pool, "club", -50, "def").await,
|
||||||
|
Err(AppError::BadRequest(_))
|
||||||
|
));
|
||||||
|
// sell with negative price hits the credit guard and rolls back the removal.
|
||||||
|
assert!(matches!(
|
||||||
|
sell_item(&pool, "club", "item-x", -1).await,
|
||||||
|
Err(AppError::BadRequest(_))
|
||||||
|
));
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_consumes_once_and_adds_items() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entitlement_id;
|
||||||
|
let items = vec![GrantedItem {
|
||||||
|
item_id: "item-a".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
}];
|
||||||
|
let def = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(def, "def-pack");
|
||||||
|
assert_eq!(item_count(&pool, "item-a").await, 1);
|
||||||
|
// Second redeem of the same entitlement is rejected; inventory unchanged.
|
||||||
|
let err = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Conflict(_)));
|
||||||
|
assert_eq!(item_count(&pool, "item-a").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_missing_entitlement_is_not_found() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert!(matches!(
|
||||||
|
redeem_entitlement(&pool, "club", "no-such", &[]).await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_partial_failure_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entitlement_id;
|
||||||
|
// Second item collides with the first instance id -> PK violation mid-loop.
|
||||||
|
let items = vec![
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
},
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let err = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Database(_)));
|
||||||
|
// Whole redemption rolled back: entitlement still unconsumed, no items added.
|
||||||
|
assert_eq!(item_count(&pool, "dup").await, 0);
|
||||||
|
let def = redeem_entitlement(
|
||||||
|
&pool,
|
||||||
|
"club",
|
||||||
|
&ent,
|
||||||
|
&[GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(def, "def-pack");
|
||||||
|
assert_eq!(item_count(&pool, "dup").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sell_removes_and_credits() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let new_balance = sell_item(&pool, "club", "item-x", 250).await.unwrap();
|
||||||
|
assert_eq!(new_balance, 1250);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 0);
|
||||||
|
// Selling it again fails; balance is unchanged.
|
||||||
|
assert!(matches!(
|
||||||
|
sell_item(&pool, "club", "item-x", 250).await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn grant_reward_credits() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert_eq!(grant_reward(&pool, "club", 500).await.unwrap(), 1500);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_item_debits_and_mints() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let bal = purchase_item(&pool, "club", 400, "item-new", "def-new")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bal, 600);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 600);
|
||||||
|
assert_eq!(item_count(&pool, "item-new").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_item_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let err = purchase_item(&pool, "club", 9000, "item-new", "def-new")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
// Nothing debited, no item minted.
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(item_count(&pool, "item-new").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_debits_and_mints_all() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
},
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-2".into(),
|
||||||
|
card_id: "d-2".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let bal = purchase_items(&pool, "club", 700, &items).await.unwrap();
|
||||||
|
assert_eq!(bal, 300);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 1);
|
||||||
|
assert_eq!(item_count(&pool, "p-2").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
}];
|
||||||
|
let err = purchase_items(&pool, "club", 9000, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::event::{EventDefinition, EventWithStatus},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use std::{collections::HashMap, path::Path};
|
||||||
|
|
||||||
|
pub fn load_event_definitions(data_dir: &str) -> anyhow::Result<Vec<EventDefinition>> {
|
||||||
|
let dir = Path::new(data_dir).join("events");
|
||||||
|
let mut defs = Vec::new();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(defs);
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(&dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&path).with_context(|| format!("reading {:?}", path))?;
|
||||||
|
let batch: Vec<EventDefinition> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {:?}", path))?;
|
||||||
|
defs.extend(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_is_active(def: &EventDefinition, override_val: Option<i64>) -> bool {
|
||||||
|
match override_val {
|
||||||
|
Some(1) => return true,
|
||||||
|
Some(0) => return false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if def.is_manual {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let after_start = def
|
||||||
|
.starts_at
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.is_none_or(|dt| now >= dt);
|
||||||
|
let before_end = def
|
||||||
|
.expires_at
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|e| chrono::DateTime::parse_from_rfc3339(e).ok())
|
||||||
|
.is_none_or(|dt| now <= dt);
|
||||||
|
after_start && before_end
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_overrides(pool: &Pool) -> AppResult<HashMap<String, Option<i64>>> {
|
||||||
|
let rows: Vec<(String, Option<i64>)> =
|
||||||
|
sqlx::query_as("SELECT id, is_active_override FROM events")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_event_active(pool: &Pool, def: &EventDefinition) -> AppResult<bool> {
|
||||||
|
let row: Option<(Option<i64>,)> =
|
||||||
|
sqlx::query_as("SELECT is_active_override FROM events WHERE id = ?")
|
||||||
|
.bind(&def.id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let override_val = row.and_then(|(v,)| v);
|
||||||
|
Ok(compute_is_active(def, override_val))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_active_events(
|
||||||
|
pool: &Pool,
|
||||||
|
defs: &[EventDefinition],
|
||||||
|
) -> AppResult<Vec<EventDefinition>> {
|
||||||
|
let overrides = fetch_overrides(pool).await?;
|
||||||
|
Ok(defs
|
||||||
|
.iter()
|
||||||
|
.filter(|def| {
|
||||||
|
let ov = overrides.get(&def.id).copied().flatten();
|
||||||
|
compute_is_active(def, ov)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventRow = (String, Option<i64>, Option<String>, Option<String>);
|
||||||
|
type EventOverrideMap = HashMap<String, (Option<i64>, Option<String>, Option<String>)>;
|
||||||
|
|
||||||
|
pub async fn get_all_with_status(
|
||||||
|
pool: &Pool,
|
||||||
|
defs: &[EventDefinition],
|
||||||
|
) -> AppResult<Vec<EventWithStatus>> {
|
||||||
|
let rows: Vec<EventRow> =
|
||||||
|
sqlx::query_as("SELECT id, is_active_override, activated_at, deactivated_at FROM events")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let db_map: EventOverrideMap = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, ov, at, dt)| (id, (ov, at, dt)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = defs
|
||||||
|
.iter()
|
||||||
|
.map(|def| {
|
||||||
|
let (override_val, activated_at, deactivated_at) = db_map
|
||||||
|
.get(&def.id)
|
||||||
|
.map(|(ov, at, dt)| (*ov, at.clone(), dt.clone()))
|
||||||
|
.unwrap_or((None, None, None));
|
||||||
|
|
||||||
|
EventWithStatus {
|
||||||
|
is_active: compute_is_active(def, override_val),
|
||||||
|
definition: def.clone(),
|
||||||
|
activated_at,
|
||||||
|
deactivated_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate_event(
|
||||||
|
pool: &Pool,
|
||||||
|
event_id: &str,
|
||||||
|
defs: &[EventDefinition],
|
||||||
|
) -> AppResult<()> {
|
||||||
|
defs.iter()
|
||||||
|
.find(|d| d.id == event_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO events (id, is_active_override, activated_at, deactivated_at)
|
||||||
|
VALUES (?, 1, ?, NULL)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
is_active_override = 1,
|
||||||
|
activated_at = excluded.activated_at,
|
||||||
|
deactivated_at = NULL",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deactivate_event(
|
||||||
|
pool: &Pool,
|
||||||
|
event_id: &str,
|
||||||
|
defs: &[EventDefinition],
|
||||||
|
) -> AppResult<()> {
|
||||||
|
defs.iter()
|
||||||
|
.find(|d| d.id == event_id)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("event '{event_id}' not found")))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO events (id, is_active_override, activated_at, deactivated_at)
|
||||||
|
VALUES (?, 0, NULL, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
is_active_override = 0,
|
||||||
|
deactivated_at = excluded.deactivated_at,
|
||||||
|
activated_at = NULL",
|
||||||
|
)
|
||||||
|
.bind(event_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::fut_champs::{ChampsTier, FutChampsSession, MAX_MATCHES},
|
||||||
|
models::pack::PackDefinition,
|
||||||
|
services::{club as club_svc, pack as pack_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_SELECT: &str =
|
||||||
|
"SELECT id, profile_id, week_number, matches_played, wins, draws, losses, \
|
||||||
|
status, reward_tier, reward_coins, reward_pack_id, reward_claimed, \
|
||||||
|
started_at, completed_at FROM fut_champs_sessions";
|
||||||
|
|
||||||
|
pub async fn get_active_session(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
) -> AppResult<Option<FutChampsSession>> {
|
||||||
|
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||||
|
"{SESSION_SELECT} WHERE profile_id = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
|
||||||
|
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||||
|
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
||||||
|
))
|
||||||
|
.bind(session_id)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("FUT Champions session not found".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult<Vec<FutChampsSession>> {
|
||||||
|
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||||
|
"{SESSION_SELECT} WHERE profile_id = ? ORDER BY started_at DESC LIMIT 20"
|
||||||
|
))
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_session(pool: &Pool, profile_id: &str) -> AppResult<FutChampsSession> {
|
||||||
|
// Only one active session at a time
|
||||||
|
if let Some(existing) = get_active_session(pool, profile_id).await? {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"active FUT Champions session already exists: {}",
|
||||||
|
existing.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine next week number from history
|
||||||
|
let week_number: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(MAX(week_number), 0) + 1 FROM fut_champs_sessions WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO fut_champs_sessions \
|
||||||
|
(id, profile_id, week_number, matches_played, wins, draws, losses, \
|
||||||
|
status, reward_claimed, started_at) \
|
||||||
|
VALUES (?, ?, ?, 0, 0, 0, 0, 'active', 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(week_number)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
get_session(pool, &id, profile_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a FUT Champions match result. Auto-completes the session at 30 matches.
|
||||||
|
pub async fn record_match(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
goals_for: i64,
|
||||||
|
goals_against: i64,
|
||||||
|
) -> AppResult<FutChampsSession> {
|
||||||
|
let session = get_session(pool, session_id, profile_id).await?;
|
||||||
|
|
||||||
|
if session.is_complete() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"this FUT Champions session is already complete".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (win_delta, draw_delta, loss_delta) = if goals_for > goals_against {
|
||||||
|
(1i64, 0i64, 0i64)
|
||||||
|
} else if goals_for == goals_against {
|
||||||
|
(0, 1, 0)
|
||||||
|
} else {
|
||||||
|
(0, 0, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE fut_champs_sessions \
|
||||||
|
SET matches_played = matches_played + 1, \
|
||||||
|
wins = wins + ?, \
|
||||||
|
draws = draws + ?, \
|
||||||
|
losses = losses + ? \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(win_delta)
|
||||||
|
.bind(draw_delta)
|
||||||
|
.bind(loss_delta)
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let updated = get_session(pool, session_id, profile_id).await?;
|
||||||
|
|
||||||
|
if updated.matches_played >= MAX_MATCHES {
|
||||||
|
complete_session(pool, &updated).await?;
|
||||||
|
get_session(pool, session_id, profile_id).await
|
||||||
|
} else {
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute tier and write reward fields; mark session completed.
|
||||||
|
async fn complete_session(pool: &Pool, session: &FutChampsSession) -> AppResult<()> {
|
||||||
|
let tier = ChampsTier::from_wins(session.wins);
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE fut_champs_sessions \
|
||||||
|
SET status = 'completed', completed_at = ?, \
|
||||||
|
reward_tier = ?, reward_coins = ?, reward_pack_id = ? \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(tier.label())
|
||||||
|
.bind(tier.reward_coins())
|
||||||
|
.bind(tier.reward_pack())
|
||||||
|
.bind(&session.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim the rewards for a completed session. Idempotent — double-claim returns an error.
|
||||||
|
pub async fn claim_rewards(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
pack_defs: &[PackDefinition],
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
let session = get_session(pool, session_id, profile_id).await?;
|
||||||
|
|
||||||
|
if !session.is_complete() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"session is still active — play all 30 matches first".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if session.reward_claimed {
|
||||||
|
return Err(AppError::Conflict("rewards already claimed".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let coins = session.reward_coins.unwrap_or(0);
|
||||||
|
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||||
|
|
||||||
|
let pack = if let Some(ref pack_id) = session.reward_pack_id {
|
||||||
|
// Only grant if the pack definition exists; ignore unknown packs gracefully
|
||||||
|
if pack_defs.iter().any(|d| &d.id == pack_id) {
|
||||||
|
let granted = pack_svc::grant_pack(pool, club_id, pack_id).await?;
|
||||||
|
Some(granted.id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query("UPDATE fut_champs_sessions SET reward_claimed = 1 WHERE id = ?")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"tier": session.reward_tier,
|
||||||
|
"coins_awarded": coins,
|
||||||
|
"new_balance": new_balance,
|
||||||
|
"pack_granted": pack,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Division Rivals weekly reward ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Rivals points awarded per Division Rivals match outcome.
|
||||||
|
pub fn rivals_points(goals_for: i64, goals_against: i64) -> i64 {
|
||||||
|
if goals_for > goals_against {
|
||||||
|
25
|
||||||
|
} else if goals_for == goals_against {
|
||||||
|
10
|
||||||
|
} else {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Weekly coin reward for Division Rivals, scaling by division.
|
||||||
|
pub fn rivals_weekly_coins(division: i64) -> i64 {
|
||||||
|
match division {
|
||||||
|
1 => 10_000,
|
||||||
|
2 => 7_500,
|
||||||
|
3 => 5_000,
|
||||||
|
4 => 3_500,
|
||||||
|
5 => 2_500,
|
||||||
|
6 | 7 => 1_500,
|
||||||
|
_ => 1_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim the weekly Division Rivals reward.
|
||||||
|
///
|
||||||
|
/// "Week" is tracked by a monotonic counter — since this is offline,
|
||||||
|
/// the player claims whenever they feel like ending their rivals week.
|
||||||
|
pub async fn claim_rivals_reward(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
pack_defs: &[PackDefinition],
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
// Fetch current season row (must exist)
|
||||||
|
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
|
||||||
|
FROM seasons WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (division, week_claimed, total_pts, last_claimed_at) =
|
||||||
|
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
||||||
|
|
||||||
|
// Enforce 24-hour cooldown between weekly reward claims
|
||||||
|
if let Some(ref last_claimed) = last_claimed_at {
|
||||||
|
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
|
||||||
|
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
||||||
|
if elapsed < chrono::Duration::hours(24) {
|
||||||
|
let hours_remaining = 24 - elapsed.num_hours();
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_week = week_claimed + 1;
|
||||||
|
let coins = rivals_weekly_coins(division);
|
||||||
|
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||||
|
|
||||||
|
// Division 1–3 get a silver pack; Division 4–5 get a bronze pack
|
||||||
|
let pack_id = match division {
|
||||||
|
1..=3 => Some("silver_pack"),
|
||||||
|
4..=5 => Some("bronze_pack"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pack = if let Some(pid) = pack_id {
|
||||||
|
if pack_defs.iter().any(|d| d.id == pid) {
|
||||||
|
let granted = pack_svc::grant_pack(pool, club_id, pid).await?;
|
||||||
|
Some(granted.id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
|
||||||
|
rivals_last_claimed_at = ? WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(next_week)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(profile_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"week_number": next_week,
|
||||||
|
"division": division,
|
||||||
|
"rivals_total_points": total_pts + 100,
|
||||||
|
"coins_awarded": coins,
|
||||||
|
"new_balance": new_balance,
|
||||||
|
"pack_granted": pack,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! Generic, game-agnostic transactional profile import.
|
||||||
|
//!
|
||||||
|
//! Core installs a profile + club + owned cards + canonical squad + one opaque
|
||||||
|
//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a
|
||||||
|
//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17
|
||||||
|
//! wire ids, resourceIds, `nextItemId`, or the extension payload — the
|
||||||
|
//! `openfut-import-fifa17` adapter reads the Python profile, chooses every
|
||||||
|
//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad
|
||||||
|
//! extension bytes, and hands Core this generic request.
|
||||||
|
//!
|
||||||
|
//! Invariants enforced here:
|
||||||
|
//! - Definition preflight: every incoming `card_id` MUST already resolve in the
|
||||||
|
//! loaded production content, so the transaction never creates ownership
|
||||||
|
//! pointing at absent content.
|
||||||
|
//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the
|
||||||
|
//! imported ownership set before the transaction begins.
|
||||||
|
//! - Rerun identity: identical `source_fingerprint` against an already-imported
|
||||||
|
//! game is an idempotent no-op; a differing token fails; a pre-existing
|
||||||
|
//! non-imported profile is never clobbered.
|
||||||
|
//! - The whole thing commits together or not at all.
|
||||||
|
|
||||||
|
use crate::db::Pool;
|
||||||
|
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
||||||
|
use crate::services::card_db::CardDb;
|
||||||
|
use crate::services::squad::squad_fingerprint;
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportProfile {
|
||||||
|
pub username: String,
|
||||||
|
pub game_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportClub {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub coins: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportOwnedCard {
|
||||||
|
/// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses
|
||||||
|
/// why it is stable — it is a primary key, nothing more.
|
||||||
|
pub owned_item_id: String,
|
||||||
|
/// CardDefinitionId that MUST resolve in loaded production content.
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportEntitlement {
|
||||||
|
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
|
||||||
|
/// id as text). Core stores it verbatim; it never interprets the value.
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportSlot {
|
||||||
|
pub owned_item_id: String,
|
||||||
|
pub position_index: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_captain: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_on_bench: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportExtension {
|
||||||
|
/// Opaque adapter key, e.g. "fifa17.squad.v1".
|
||||||
|
pub namespace: String,
|
||||||
|
/// Adapter payload version (distinct from DB storage schema).
|
||||||
|
pub schema_version: i64,
|
||||||
|
/// Uninterpreted bytes-as-text. Core enforces only generic size bounds.
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportSquad {
|
||||||
|
pub formation: String,
|
||||||
|
#[serde(default = "default_squad_name")]
|
||||||
|
pub name: String,
|
||||||
|
pub slots: Vec<ImportSlot>,
|
||||||
|
pub extension: ImportExtension,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_squad_name() -> String {
|
||||||
|
"My Squad".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProfileImportRequest {
|
||||||
|
/// Generic provenance/rerun-identity token. Core stores it verbatim.
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
pub profile: ImportProfile,
|
||||||
|
pub club: ImportClub,
|
||||||
|
pub owned: Vec<ImportOwnedCard>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub squad: Option<ImportSquad>,
|
||||||
|
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub entitlements: Vec<ImportEntitlement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||||
|
pub enum ImportOutcome {
|
||||||
|
/// A fresh import committed.
|
||||||
|
Imported { owned: usize, squad_slots: usize },
|
||||||
|
/// The same fingerprint was already imported for this game — no-op.
|
||||||
|
AlreadyImported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a generic transactional profile import. See module docs for invariants.
|
||||||
|
pub async fn apply_profile_import(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
req: &ProfileImportRequest,
|
||||||
|
) -> Result<ImportOutcome> {
|
||||||
|
// ── 0. generic input validation (no writes) ──
|
||||||
|
if req.source_fingerprint.trim().is_empty() {
|
||||||
|
bail!("source_fingerprint must be non-empty");
|
||||||
|
}
|
||||||
|
if req.owned.is_empty() {
|
||||||
|
bail!("import request has zero owned cards; refusing to import an empty profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. rerun identity / single-profile-per-game ──
|
||||||
|
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT id, import_fingerprint FROM profiles \
|
||||||
|
WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&req.profile.game_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
if let Some((_id, fp)) = existing {
|
||||||
|
match fp {
|
||||||
|
Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported),
|
||||||
|
Some(fp) => bail!(
|
||||||
|
"game '{}' already imported from a different source (stored fingerprint {fp}, \
|
||||||
|
incoming {}); refusing to overwrite without an explicit update mode",
|
||||||
|
req.profile.game_id,
|
||||||
|
req.source_fingerprint
|
||||||
|
),
|
||||||
|
None => bail!(
|
||||||
|
"game '{}' already has a non-imported profile; refusing to clobber it",
|
||||||
|
req.profile.game_id
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. definition preflight: every card_id MUST resolve in loaded content ──
|
||||||
|
let mut missing: Vec<&str> = req
|
||||||
|
.owned
|
||||||
|
.iter()
|
||||||
|
.filter(|o| card_db.get(&o.card_id).is_none())
|
||||||
|
.map(|o| o.card_id.as_str())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
missing.sort_unstable();
|
||||||
|
missing.dedup();
|
||||||
|
let sample = &missing[..missing.len().min(5)];
|
||||||
|
bail!(
|
||||||
|
"definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \
|
||||||
|
loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content",
|
||||||
|
missing.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. owned-item-id uniqueness ──
|
||||||
|
let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len());
|
||||||
|
for o in &req.owned {
|
||||||
|
if !owned_ids.insert(o.owned_item_id.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"duplicate OwnedItemId in import request: {}",
|
||||||
|
o.owned_item_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. squad all-or-nothing + generic extension bounds (no writes) ──
|
||||||
|
if let Some(sq) = &req.squad {
|
||||||
|
let ns_len = sq.extension.namespace.len();
|
||||||
|
if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN {
|
||||||
|
bail!(
|
||||||
|
"extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
||||||
|
bail!(
|
||||||
|
"extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})",
|
||||||
|
sq.extension.payload.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for slot in &sq.slots {
|
||||||
|
if !owned_ids.contains(slot.owned_item_id.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"active squad references OwnedItemId {} not present in imported ownership set; \
|
||||||
|
squad import is all-or-nothing",
|
||||||
|
slot.owned_item_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. single transaction: everything commits together or not at all ──
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let profile_id = Uuid::new_v4().to_string();
|
||||||
|
let club_id = Uuid::new_v4().to_string();
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \
|
||||||
|
VALUES (?, ?, 1, 0, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&profile_id)
|
||||||
|
.bind(&req.profile.username)
|
||||||
|
.bind(&req.profile.game_id)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&req.source_fingerprint)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("insert profile")?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&profile_id)
|
||||||
|
.bind(&req.club.name)
|
||||||
|
.bind(req.club.coins)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("insert club")?;
|
||||||
|
|
||||||
|
for o in &req.owned {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(&o.owned_item_id)
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&o.card_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for e in &req.entitlements {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&e.definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut squad_slots = 0usize;
|
||||||
|
if let Some(sq) = &req.squad {
|
||||||
|
let squad_id = Uuid::new_v4().to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&squad_id)
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&sq.name)
|
||||||
|
.bind(&sq.formation)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("insert squad")?;
|
||||||
|
|
||||||
|
for slot in &sq.slots {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(&squad_id)
|
||||||
|
.bind(&slot.owned_item_id)
|
||||||
|
.bind(slot.position_index)
|
||||||
|
.bind(slot.is_captain)
|
||||||
|
.bind(slot.is_on_bench)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("insert squad_player")?;
|
||||||
|
}
|
||||||
|
squad_slots = sq.slots.len();
|
||||||
|
|
||||||
|
// Core computes the canonical fingerprint over the COMMITTED squad — never
|
||||||
|
// an adapter-supplied value — and persists the opaque extension atomically
|
||||||
|
// in the same tx, exactly as the live squad-write path does.
|
||||||
|
let canonical_fingerprint = squad_fingerprint(
|
||||||
|
&squad_id,
|
||||||
|
&sq.formation,
|
||||||
|
sq.slots.iter().map(|s| {
|
||||||
|
(
|
||||||
|
s.position_index,
|
||||||
|
s.owned_item_id.as_str(),
|
||||||
|
s.is_captain,
|
||||||
|
s.is_on_bench,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO game_entity_ext \
|
||||||
|
(game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \
|
||||||
|
VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&req.profile.game_id)
|
||||||
|
.bind(&squad_id)
|
||||||
|
.bind(&sq.extension.namespace)
|
||||||
|
.bind(sq.extension.schema_version)
|
||||||
|
.bind(&canonical_fingerprint)
|
||||||
|
.bind(&sq.extension.payload)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("insert game_entity_ext")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(ImportOutcome::Imported {
|
||||||
|
owned: req.owned.len(),
|
||||||
|
squad_slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-24
@@ -3,11 +3,12 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
card::CardDefinition,
|
card::CardDefinition,
|
||||||
|
event::EventDefinition,
|
||||||
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
market::{BuyListingRequest, MarketListing, MarketListingWithCard, SellCardRequest},
|
||||||
},
|
},
|
||||||
services::{card_db::CardDb, club},
|
services::{card_db::CardDb, club, event as event_svc},
|
||||||
};
|
};
|
||||||
use rand::Rng;
|
use rand::{seq::SliceRandom, Rng};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn get_active_listings(
|
pub async fn get_active_listings(
|
||||||
@@ -15,7 +16,9 @@ pub async fn get_active_listings(
|
|||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
) -> AppResult<Vec<MarketListingWithCard>> {
|
) -> AppResult<Vec<MarketListingWithCard>> {
|
||||||
let listings = sqlx::query_as::<_, MarketListing>(
|
let listings = sqlx::query_as::<_, MarketListing>(
|
||||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') ORDER BY listed_at DESC LIMIT 50"
|
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
|
||||||
|
FROM market_listings WHERE sold = 0 AND expires_at > datetime('now') \
|
||||||
|
ORDER BY listed_at DESC LIMIT 50",
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -34,22 +37,32 @@ pub async fn get_active_listings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh NPC market with random listings from the card pool.
|
/// Refresh NPC market with random listings from the card pool.
|
||||||
pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<usize> {
|
/// Also injects bonus cards from currently active events.
|
||||||
// Clean up expired listings first
|
pub async fn refresh_npc_listings(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
event_defs: &[EventDefinition],
|
||||||
|
) -> AppResult<usize> {
|
||||||
|
// Clean up expired listings and previous NPC listings.
|
||||||
|
// Player-posted listings (is_npc = 0) are intentionally preserved.
|
||||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Build all listings synchronously before any await — drops &CardDefinition refs before first .await
|
// Resolve active events before entering the sync block (no await inside)
|
||||||
|
let active_events = event_svc::get_active_events(pool, event_defs).await?;
|
||||||
|
|
||||||
|
// Build all listings synchronously — ThreadRng and &CardDefinition refs
|
||||||
|
// are both dropped before the first .await below.
|
||||||
let listings_to_insert: Vec<MarketListing> = {
|
let listings_to_insert: Vec<MarketListing> = {
|
||||||
let all_cards: Vec<&CardDefinition> = card_db.all();
|
let all_cards: Vec<&CardDefinition> = card_db.all();
|
||||||
if all_cards.is_empty() {
|
if all_cards.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let count = 20usize.min(all_cards.len());
|
let count = 24usize.min(all_cards.len());
|
||||||
let npc_names = [
|
let npc_names = [
|
||||||
"FC Rovers NPC",
|
"FC Rovers NPC",
|
||||||
"Market Bot",
|
"Market Bot",
|
||||||
@@ -59,22 +72,44 @@ pub async fn refresh_npc_listings(pool: &Pool, card_db: &CardDb) -> AppResult<us
|
|||||||
"City AI Club",
|
"City AI Club",
|
||||||
];
|
];
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
all_cards
|
|
||||||
.iter()
|
let mut indices: Vec<usize> = (0..all_cards.len()).collect();
|
||||||
|
indices.shuffle(&mut rng);
|
||||||
|
|
||||||
|
let mut listings: Vec<MarketListing> = indices
|
||||||
|
.into_iter()
|
||||||
.take(count)
|
.take(count)
|
||||||
.map(|card| {
|
.map(|i| {
|
||||||
let base_price = price_for_card(card.overall);
|
let card = all_cards[i];
|
||||||
let price = rng.gen_range((base_price / 2)..=(base_price * 2)).max(100);
|
let base = price_for_card(card.overall);
|
||||||
|
// ±25% variance for realistic NPC pricing
|
||||||
|
let pct = rng.gen_range(75i64..=125i64);
|
||||||
|
let price = (base * pct / 100).max(100);
|
||||||
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
let seller = npc_names[rng.gen_range(0..npc_names.len())];
|
||||||
MarketListing::new(&card.id, seller, price)
|
MarketListing::new(&card.id, seller, price)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
}; // all_cards refs and ThreadRng both dropped here
|
|
||||||
|
// Inject event bonus cards as special NPC listings
|
||||||
|
for event in &active_events {
|
||||||
|
for card_id in &event.effects.bonus_market_cards {
|
||||||
|
if let Some(card) = card_db.get(card_id) {
|
||||||
|
let price = price_for_card(card.overall) * 2; // premium pricing
|
||||||
|
let label = format!("[EVENT] {}", event.name);
|
||||||
|
listings.push(MarketListing::new(card_id.as_str(), label.as_str(), price));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listings
|
||||||
|
}; // rng + card refs + active_events all dropped here
|
||||||
|
|
||||||
let mut inserted = 0;
|
let mut inserted = 0;
|
||||||
for listing in &listings_to_insert {
|
for listing in &listings_to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO market_listings (id, card_id, seller_name, price, listed_at, expires_at, sold) VALUES (?, ?, ?, ?, ?, ?, 0)"
|
"INSERT INTO market_listings \
|
||||||
|
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
|
||||||
)
|
)
|
||||||
.bind(&listing.id)
|
.bind(&listing.id)
|
||||||
.bind(&listing.card_id)
|
.bind(&listing.card_id)
|
||||||
@@ -98,7 +133,8 @@ pub async fn buy_listing(
|
|||||||
req: &BuyListingRequest,
|
req: &BuyListingRequest,
|
||||||
) -> AppResult<CardDefinition> {
|
) -> AppResult<CardDefinition> {
|
||||||
let listing = sqlx::query_as::<_, MarketListing>(
|
let listing = sqlx::query_as::<_, MarketListing>(
|
||||||
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold FROM market_listings WHERE id = ? AND sold = 0"
|
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
|
||||||
|
FROM market_listings WHERE id = ? AND sold = 0",
|
||||||
)
|
)
|
||||||
.bind(&req.listing_id)
|
.bind(&req.listing_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -114,7 +150,9 @@ pub async fn buy_listing(
|
|||||||
|
|
||||||
let owned_id = Uuid::new_v4().to_string();
|
let owned_id = Uuid::new_v4().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) VALUES (?, ?, ?, 0, NULL, ?)"
|
"INSERT INTO owned_cards \
|
||||||
|
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
)
|
)
|
||||||
.bind(&owned_id)
|
.bind(&owned_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
@@ -123,15 +161,44 @@ pub async fn buy_listing(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
card_db
|
let card = card_db
|
||||||
.get(&listing.card_id)
|
.get(&listing.card_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| AppError::NotFound("card definition not found".into()))
|
.ok_or_else(|| AppError::NotFound("card definition not found".into()))?;
|
||||||
|
|
||||||
|
// Record trade history (best-effort — never abort the buy on failure)
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
|
||||||
|
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(&card.id)
|
||||||
|
.bind(&card.name)
|
||||||
|
.bind(card.overall as i64)
|
||||||
|
.bind("buy")
|
||||||
|
.bind(listing.price)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> AppResult<i64> {
|
pub async fn sell_card(
|
||||||
let _owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
pool: &Pool,
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
card_db: &CardDb,
|
||||||
|
club_id: &str,
|
||||||
|
req: &SellCardRequest,
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
if req.price < 0 {
|
||||||
|
return Err(AppError::BadRequest("price must be non-negative".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
|
FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&req.owned_card_id)
|
.bind(&req.owned_card_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
@@ -144,12 +211,56 @@ pub async fn sell_card(pool: &Pool, club_id: &str, req: &SellCardRequest) -> App
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Quick-sell price: 40% of requested price
|
|
||||||
let coins = (req.price as f64 * 0.4) as i64;
|
let coins = (req.price as f64 * 0.4) as i64;
|
||||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||||
|
|
||||||
|
// Record trade history
|
||||||
|
if let Some(card) = card_db.get(&owned.card_id) {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO market_history (id, club_id, card_id, card_name, card_overall, \
|
||||||
|
trade_type, price, traded_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(&card.id)
|
||||||
|
.bind(&card.name)
|
||||||
|
.bind(card.overall as i64)
|
||||||
|
.bind("sell")
|
||||||
|
.bind(coins)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(new_balance)
|
Ok(new_balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the last 30 buy/sell events for this club, newest first.
|
||||||
|
pub async fn get_trade_history(pool: &Pool, club_id: &str) -> AppResult<Vec<serde_json::Value>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT card_name, card_overall, trade_type, price, traded_at \
|
||||||
|
FROM market_history WHERE club_id = ? ORDER BY traded_at DESC LIMIT 30",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
use sqlx::Row;
|
||||||
|
let trades = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"card_name": r.get::<String, _>("card_name"),
|
||||||
|
"card_overall": r.get::<i64, _>("card_overall"),
|
||||||
|
"trade_type": r.get::<String, _>("trade_type"),
|
||||||
|
"price": r.get::<i64, _>("price"),
|
||||||
|
"traded_at": r.get::<String, _>("traded_at"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(trades)
|
||||||
|
}
|
||||||
|
|
||||||
fn price_for_card(overall: u8) -> i64 {
|
fn price_for_card(overall: u8) -> i64 {
|
||||||
match overall {
|
match overall {
|
||||||
85..=u8::MAX => 50_000,
|
85..=u8::MAX => 50_000,
|
||||||
@@ -160,3 +271,63 @@ fn price_for_card(overall: u8) -> i64 {
|
|||||||
_ => 200,
|
_ => 200,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return all active listings posted by the given club (player listings only).
|
||||||
|
pub async fn get_listings_by_seller(
|
||||||
|
pool: &Pool,
|
||||||
|
card_db: &CardDb,
|
||||||
|
club_id: &str,
|
||||||
|
) -> AppResult<Vec<MarketListingWithCard>> {
|
||||||
|
let listings = sqlx::query_as::<_, MarketListing>(
|
||||||
|
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
|
||||||
|
FROM market_listings WHERE sold = 0 AND seller_name = ? \
|
||||||
|
AND expires_at > datetime('now') ORDER BY listed_at DESC",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let with_cards = listings
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|l| {
|
||||||
|
card_db
|
||||||
|
.get(&l.card_id)
|
||||||
|
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(with_cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel a player-posted listing and return the card to the owner's collection.
|
||||||
|
pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> AppResult<()> {
|
||||||
|
let listing = sqlx::query_as::<_, MarketListing>(
|
||||||
|
"SELECT id, card_id, seller_name, price, listed_at, expires_at, sold \
|
||||||
|
FROM market_listings WHERE id = ? AND seller_name = ? AND sold = 0",
|
||||||
|
)
|
||||||
|
.bind(listing_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM market_listings WHERE id = ?")
|
||||||
|
.bind(listing_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Return the card to the collection
|
||||||
|
let owned_id = Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT 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(&listing.card_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+143
-38
@@ -2,80 +2,79 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::{
|
models::{
|
||||||
|
achievement::AchievementDefinition,
|
||||||
|
card::OwnedCard,
|
||||||
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
objective::ObjectiveDefinition,
|
objective::ObjectiveDefinition,
|
||||||
},
|
},
|
||||||
services::{card_db::CardDb, club, objective, profile, statistics},
|
services::{achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc, statistics},
|
||||||
};
|
};
|
||||||
use rand::{seq::SliceRandom, Rng};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
|
const FORMATIONS: &[&str] = &[
|
||||||
|
"4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2",
|
||||||
|
];
|
||||||
|
|
||||||
/// Generate a random AI opponent squad for Squad Battles.
|
/// Generate a random AI opponent squad for Squad Battles.
|
||||||
|
///
|
||||||
|
/// Difficulty bands:
|
||||||
|
/// beginner — overall 55+ (same as "any")
|
||||||
|
/// professional — overall 70+
|
||||||
|
/// world_class — overall 78+
|
||||||
|
/// legendary — overall 85+
|
||||||
|
/// ultimate — overall 90+
|
||||||
pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value {
|
pub fn generate_opponent(card_db: &CardDb, difficulty: &str) -> serde_json::Value {
|
||||||
let (min_overall, names): (u8, &[&str]) = match difficulty {
|
let (min_overall, names): (u8, &[&str]) = match difficulty {
|
||||||
"professional" => (
|
"professional" => (
|
||||||
70,
|
70,
|
||||||
&[
|
&["Athletic CF", "City Wanderers", "The Rovers", "United Select", "Blue Stars FC"],
|
||||||
"Athletic CF",
|
|
||||||
"City Wanderers",
|
|
||||||
"The Rovers",
|
|
||||||
"United Select",
|
|
||||||
"Blue Stars FC",
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
"world_class" => (
|
"world_class" => (
|
||||||
78,
|
78,
|
||||||
&[
|
&["Elite Stars FC", "Champions Select", "Premier XI", "Galaxy United", "Titan FC"],
|
||||||
"Elite Stars FC",
|
|
||||||
"Champions Select",
|
|
||||||
"Premier XI",
|
|
||||||
"Galaxy United",
|
|
||||||
"Titan FC",
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
"legendary" => (
|
"legendary" => (
|
||||||
85,
|
85,
|
||||||
&[
|
&["Legends United", "Ultimate XI", "Gold Standard FC", "The Icons", "Heritage FC"],
|
||||||
"Legends United",
|
),
|
||||||
"Ultimate XI",
|
"ultimate" => (
|
||||||
"Gold Standard FC",
|
90,
|
||||||
"The Icons",
|
&["Apex XI", "Pantheon FC", "Gods of FUT", "Invincibles Select", "Eternal XI"],
|
||||||
"Heritage FC",
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
58,
|
55,
|
||||||
&[
|
&["Amateur Town FC", "Sunday League XI", "Park FC", "Village Stars", "Reserve XI"],
|
||||||
"Amateur Town FC",
|
|
||||||
"Sunday League XI",
|
|
||||||
"Park FC",
|
|
||||||
"Village Stars",
|
|
||||||
"Reserve XI",
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let all = card_db.by_min_overall(min_overall);
|
let all = card_db.by_min_overall(min_overall);
|
||||||
let mut indices: Vec<usize> = (0..all.len()).collect();
|
|
||||||
|
// Fall back to lower overall band if not enough cards at this difficulty
|
||||||
|
let pool: Vec<_> = if all.len() >= 11 {
|
||||||
|
all
|
||||||
|
} else {
|
||||||
|
card_db.by_min_overall(55)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
||||||
indices.shuffle(&mut rng);
|
indices.shuffle(&mut rng);
|
||||||
let cards: Vec<_> = indices
|
let cards: Vec<_> = indices.into_iter().take(11).map(|i| pool[i].clone()).collect();
|
||||||
.into_iter()
|
|
||||||
.take(11)
|
|
||||||
.map(|i| all[i].clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let squad_rating = if cards.is_empty() {
|
let squad_rating = if cards.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64
|
cards.iter().map(|c| c.overall as i64).sum::<i64>() / cards.len() as i64
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = names[rng.gen_range(0..names.len())];
|
let name = names[rng.gen_range(0..names.len())];
|
||||||
|
let formation = FORMATIONS[rng.gen_range(0..FORMATIONS.len())];
|
||||||
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"opponent_name": name,
|
"opponent_name": name,
|
||||||
"difficulty": difficulty,
|
"difficulty": difficulty,
|
||||||
"squad_rating": squad_rating,
|
"squad_rating": squad_rating,
|
||||||
"formation": "4-4-2",
|
"formation": formation,
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,14 @@ pub async fn process_match(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
req: &SubmitMatchRequest,
|
req: &SubmitMatchRequest,
|
||||||
obj_defs: &[ObjectiveDefinition],
|
obj_defs: &[ObjectiveDefinition],
|
||||||
|
ach_defs: &[AchievementDefinition],
|
||||||
) -> AppResult<MatchRewardResult> {
|
) -> AppResult<MatchRewardResult> {
|
||||||
|
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
||||||
|
return Err(crate::error::AppError::BadRequest(
|
||||||
|
"goals_for and goals_against must each be between 0 and 99".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let outcome = if req.goals_for > req.goals_against {
|
let outcome = if req.goals_for > req.goals_against {
|
||||||
"win"
|
"win"
|
||||||
} else if req.goals_for == req.goals_against {
|
} else if req.goals_for == req.goals_against {
|
||||||
@@ -137,7 +143,17 @@ pub async fn process_match(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
profile::add_xp(pool, profile_id, xp).await?;
|
let level_ups = profile::add_xp_with_levelup(pool, profile_id, club_id, xp).await?;
|
||||||
|
|
||||||
|
for ev in &level_ups {
|
||||||
|
let body = if let Some(ref pack) = ev.pack_granted {
|
||||||
|
format!("You reached level {}! Reward: {} coins + {pack}.", ev.new_level, ev.coins_granted)
|
||||||
|
} else {
|
||||||
|
format!("You reached level {}! Reward: {} coins.", ev.new_level, ev.coins_granted)
|
||||||
|
};
|
||||||
|
let _ = notification::create(pool, "level_up", &format!("Level {}!", ev.new_level), &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
statistics::record_match(
|
statistics::record_match(
|
||||||
pool,
|
pool,
|
||||||
profile_id,
|
profile_id,
|
||||||
@@ -173,10 +189,99 @@ pub async fn process_match(
|
|||||||
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
objective::increment_metric(pool, profile_id, obj_defs, "coinsearned", coins).await?;
|
||||||
objectives_updated.append(&mut c);
|
objectives_updated.append(&mut c);
|
||||||
|
|
||||||
|
for obj_id in &objectives_updated {
|
||||||
|
let display_name = obj_defs
|
||||||
|
.iter()
|
||||||
|
.find(|d| &d.id == obj_id)
|
||||||
|
.map(|d| d.title.as_str())
|
||||||
|
.unwrap_or(obj_id.as_str());
|
||||||
|
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
|
||||||
|
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||||
|
let expired_loans = process_loan_expiry(pool, club_id, &req.squad_id).await?;
|
||||||
|
|
||||||
|
for owned_id in &expired_loans {
|
||||||
|
let body = format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
|
||||||
|
let _ = notification::create(pool, "loan_expired", "Loan card expired", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update season progress (creates the season row if it doesn't exist yet).
|
||||||
|
season_svc::get_or_create(pool, profile_id).await?;
|
||||||
|
let (_, season_end) = season_svc::record_match(pool, club_id, profile_id, outcome).await?;
|
||||||
|
|
||||||
|
if let Some(ref se) = season_end {
|
||||||
|
use crate::models::season::SeasonResult;
|
||||||
|
let direction = match se.result {
|
||||||
|
SeasonResult::Promoted => "Promoted",
|
||||||
|
SeasonResult::Relegated => "Relegated",
|
||||||
|
SeasonResult::Maintained => "Maintained",
|
||||||
|
};
|
||||||
|
let body = format!("{direction} — now in Division {}. Rewards: {} coins{}.", se.new_division, se.coins_awarded, se.pack_awarded.as_deref().map(|p| format!(" + {p}")).unwrap_or_default());
|
||||||
|
let _ = notification::create(pool, "season_end", "Season complete!", &body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let achievements_unlocked =
|
||||||
|
achievement::check_and_unlock(pool, ach_defs, profile_id, club_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(MatchRewardResult {
|
Ok(MatchRewardResult {
|
||||||
match_record,
|
match_record,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
xp_awarded: xp,
|
xp_awarded: xp,
|
||||||
objectives_updated,
|
objectives_updated,
|
||||||
|
expired_loans,
|
||||||
|
season_end,
|
||||||
|
level_ups,
|
||||||
|
achievements_unlocked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrement `loan_matches_remaining` for each loan card in the squad's starting XI.
|
||||||
|
/// Removes cards whose remaining count hits 0 and returns their owned_card_ids.
|
||||||
|
async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<Vec<String>> {
|
||||||
|
// Get starters (is_on_bench = 0) for this squad
|
||||||
|
let starters: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT sp.id, sp.owned_card_id FROM squad_players sp \
|
||||||
|
JOIN squads s ON s.id = sp.squad_id \
|
||||||
|
WHERE sp.squad_id = ? AND sp.is_on_bench = 0 AND s.club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(squad_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut expired = Vec::new();
|
||||||
|
|
||||||
|
for (_sp_id, owned_id) in starters {
|
||||||
|
let card = sqlx::query_as::<_, OwnedCard>(
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
||||||
|
FROM owned_cards WHERE id = ? AND is_loan = 1",
|
||||||
|
)
|
||||||
|
.bind(&owned_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(c) = card {
|
||||||
|
let remaining = c.loan_matches_remaining.unwrap_or(0);
|
||||||
|
if remaining <= 1 {
|
||||||
|
// Loan expired — remove from collection
|
||||||
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||||
|
.bind(&owned_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
expired.push(owned_id);
|
||||||
|
} else {
|
||||||
|
sqlx::query("UPDATE owned_cards SET loan_matches_remaining = ? WHERE id = ?")
|
||||||
|
.bind(remaining - 1)
|
||||||
|
.bind(&owned_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(expired)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
pub mod achievement;
|
||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod draft;
|
||||||
|
pub mod economy;
|
||||||
|
pub mod event;
|
||||||
|
pub mod fut_champs;
|
||||||
|
pub mod game_ext;
|
||||||
|
pub mod import;
|
||||||
|
pub mod inventory;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
|
pub mod notification;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sbc;
|
pub mod sbc;
|
||||||
|
pub mod season;
|
||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::{db::Pool, error::AppResult, models::notification::Notification};
|
||||||
|
|
||||||
|
pub async fn create(pool: &Pool, kind: &str, title: &str, body: &str) -> AppResult<()> {
|
||||||
|
let n = Notification::new(kind, title, body);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) \
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&n.id)
|
||||||
|
.bind(&n.kind)
|
||||||
|
.bind(&n.title)
|
||||||
|
.bind(&n.body)
|
||||||
|
.bind(&n.created_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &Pool) -> AppResult<Vec<Notification>> {
|
||||||
|
sqlx::query_as::<_, Notification>(
|
||||||
|
"SELECT id, kind, title, body, is_read, created_at \
|
||||||
|
FROM notifications ORDER BY created_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_read(pool: &Pool, id: &str) -> AppResult<bool> {
|
||||||
|
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_all_read(pool: &Pool) -> AppResult<i64> {
|
||||||
|
let r = sqlx::query("UPDATE notifications SET is_read = 1 WHERE is_read = 0")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unread_count(pool: &Pool) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE is_read = 0")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ pub async fn increment_metric(
|
|||||||
|
|
||||||
for def in defs
|
for def in defs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
.filter(|d| d.metric.as_str() == metric)
|
||||||
{
|
{
|
||||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||||
|
|||||||
+13
-5
@@ -39,6 +39,8 @@ pub async fn grant_pack(pool: &Pool, club_id: &str, definition_id: &str) -> AppR
|
|||||||
definition_id: definition_id.to_string(),
|
definition_id: definition_id.to_string(),
|
||||||
opened: false,
|
opened: false,
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
opened_cards: None,
|
||||||
|
opened_at: None,
|
||||||
};
|
};
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
@@ -61,7 +63,7 @@ pub async fn open_pack(
|
|||||||
pack_id: &str,
|
pack_id: &str,
|
||||||
) -> AppResult<PackOpenResult> {
|
) -> AppResult<PackOpenResult> {
|
||||||
let pack = sqlx::query_as::<_, Pack>(
|
let pack = sqlx::query_as::<_, Pack>(
|
||||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE id = ? AND club_id = ?"
|
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE id = ? AND club_id = ?"
|
||||||
)
|
)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
@@ -88,8 +90,8 @@ pub async fn open_pack(
|
|||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
let r = c.rarity.as_str();
|
||||||
rarities.contains(&r)
|
rarities.contains(&r.to_string())
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -122,7 +124,13 @@ pub async fn open_pack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ?")
|
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||||
|
.bind(&card_ids_json)
|
||||||
|
.bind(&now)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -152,7 +160,7 @@ pub async fn buy_pack(
|
|||||||
|
|
||||||
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
|
pub async fn get_unopened_packs(pool: &Pool, club_id: &str) -> AppResult<Vec<Pack>> {
|
||||||
let packs = sqlx::query_as::<_, Pack>(
|
let packs = sqlx::query_as::<_, Pack>(
|
||||||
"SELECT id, club_id, definition_id, opened, created_at FROM packs WHERE club_id = ? AND opened = 0"
|
"SELECT id, club_id, definition_id, opened, created_at, opened_cards, opened_at FROM packs WHERE club_id = ? AND opened = 0"
|
||||||
)
|
)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|||||||
+63
-8
@@ -1,37 +1,45 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::profile::Profile,
|
models::profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent, Profile},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
||||||
|
/// one profile row per `game_id`, so we take the earliest for that game.
|
||||||
|
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
||||||
sqlx::query_as::<_, Profile>(
|
sqlx::query_as::<_, Profile>(
|
||||||
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
||||||
|
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||||
)
|
)
|
||||||
|
.bind(game_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
||||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
// Single-player per game: one profile per game_id, not one globally.
|
||||||
|
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||||
|
.bind(game_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if existing > 0 {
|
if existing > 0 {
|
||||||
return Err(AppError::Conflict(
|
return Err(AppError::Conflict(
|
||||||
"a profile already exists; OpenFUT is single-player only".into(),
|
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = Profile::new(username);
|
let profile = Profile::new(username, game_id);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.bind(&profile.username)
|
.bind(&profile.username)
|
||||||
.bind(profile.level)
|
.bind(profile.level)
|
||||||
.bind(profile.xp)
|
.bind(profile.xp)
|
||||||
|
.bind(&profile.game_id)
|
||||||
.bind(profile.created_at)
|
.bind(profile.created_at)
|
||||||
.bind(profile.updated_at)
|
.bind(profile.updated_at)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -50,3 +58,50 @@ pub async fn add_xp(pool: &Pool, profile_id: &str, xp: i64) -> AppResult<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add XP, check for level-ups, grant rewards, and return every level gained.
|
||||||
|
/// Callers should use this instead of `add_xp` when level-up feedback matters.
|
||||||
|
pub async fn add_xp_with_levelup(
|
||||||
|
pool: &Pool,
|
||||||
|
profile_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
xp_to_add: i64,
|
||||||
|
) -> AppResult<Vec<LevelUpEvent>> {
|
||||||
|
let profile = sqlx::query_as::<_, Profile>(
|
||||||
|
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
||||||
|
let old_level = level_for_xp(profile.xp);
|
||||||
|
let new_total_xp = profile.xp + xp_to_add;
|
||||||
|
let new_level = level_for_xp(new_total_xp);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(new_total_xp)
|
||||||
|
.bind(new_level)
|
||||||
|
.bind(now)
|
||||||
|
.bind(profile_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for lvl in (old_level + 1)..=new_level {
|
||||||
|
let coins = coins_for_level(lvl);
|
||||||
|
let pack = pack_for_level(lvl).map(String::from);
|
||||||
|
|
||||||
|
if coins > 0 {
|
||||||
|
crate::services::club::add_coins(pool, club_id, coins).await?;
|
||||||
|
}
|
||||||
|
if let Some(ref pack_id) = pack {
|
||||||
|
crate::services::pack::grant_pack(pool, club_id, pack_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(profile_id, new_level = lvl, coins, "level up");
|
||||||
|
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ pub async fn submit_sbc(
|
|||||||
let mut cards: Vec<CardDefinition> = Vec::new();
|
let mut cards: Vec<CardDefinition> = Vec::new();
|
||||||
for owned_id in &req.owned_card_ids {
|
for owned_id in &req.owned_card_ids {
|
||||||
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at FROM owned_cards WHERE id = ? AND club_id = ?"
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?"
|
||||||
)
|
)
|
||||||
.bind(owned_id)
|
.bind(owned_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::AppResult,
|
||||||
|
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||||
|
services::{club, pack},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Get the current season for a profile, creating it if it doesn't exist.
|
||||||
|
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
||||||
|
if let Some(s) = fetch(pool, profile_id).await? {
|
||||||
|
return Ok(s);
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO seasons (profile_id, division, season_number, season_points, \
|
||||||
|
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||||
|
let s = sqlx::query_as::<_, Season>(
|
||||||
|
"SELECT profile_id, division, season_number, season_points, matches_played, \
|
||||||
|
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a match result in the season; end the season if the quota is met.
|
||||||
|
///
|
||||||
|
/// Returns the updated season and an optional end-of-season summary.
|
||||||
|
pub async fn record_match(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
profile_id: &str,
|
||||||
|
outcome: &str,
|
||||||
|
) -> AppResult<(Season, Option<SeasonEndSummary>)> {
|
||||||
|
let points = match outcome {
|
||||||
|
"win" => 3,
|
||||||
|
"draw" => 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE seasons SET \
|
||||||
|
season_points = season_points + ?, \
|
||||||
|
matches_played = matches_played + 1, \
|
||||||
|
wins = wins + CASE WHEN ? = 'win' THEN 1 ELSE 0 END, \
|
||||||
|
draws = draws + CASE WHEN ? = 'draw' THEN 1 ELSE 0 END, \
|
||||||
|
losses = losses + CASE WHEN ? = 'loss' THEN 1 ELSE 0 END \
|
||||||
|
WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(points)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(outcome)
|
||||||
|
.bind(profile_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let season = fetch(pool, profile_id).await?.expect("season must exist");
|
||||||
|
|
||||||
|
if !season.is_complete() {
|
||||||
|
return Ok((season, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Season complete — calculate result and start next
|
||||||
|
let result = season.end_result();
|
||||||
|
let old_div = season.division;
|
||||||
|
let coins = season.season_reward_coins();
|
||||||
|
let pack_id = season.season_reward_pack();
|
||||||
|
|
||||||
|
let new_div = match result {
|
||||||
|
SeasonResult::Promoted => (old_div - 1).max(1),
|
||||||
|
SeasonResult::Relegated => (old_div + 1).min(10),
|
||||||
|
SeasonResult::Maintained => old_div,
|
||||||
|
};
|
||||||
|
let new_season = season.season_number + 1;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
||||||
|
matches_played = 0, wins = 0, draws = 0, losses = 0, started_at = ? \
|
||||||
|
WHERE profile_id = ?",
|
||||||
|
)
|
||||||
|
.bind(new_div)
|
||||||
|
.bind(new_season)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(profile_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Grant rewards
|
||||||
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
|
if let Some(pack_def) = pack_id {
|
||||||
|
pack::grant_pack(pool, club_id, pack_def).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist history entry before rolling over
|
||||||
|
let result_str = match result {
|
||||||
|
SeasonResult::Promoted => "promoted",
|
||||||
|
SeasonResult::Maintained => "maintained",
|
||||||
|
SeasonResult::Relegated => "relegated",
|
||||||
|
};
|
||||||
|
let history_id = Uuid::new_v4().to_string();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
||||||
|
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&history_id)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(season.season_number)
|
||||||
|
.bind(old_div)
|
||||||
|
.bind(season.season_points)
|
||||||
|
.bind(season.wins)
|
||||||
|
.bind(season.draws)
|
||||||
|
.bind(season.losses)
|
||||||
|
.bind(result_str)
|
||||||
|
.bind(new_div)
|
||||||
|
.bind(coins)
|
||||||
|
.bind(pack_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let summary = SeasonEndSummary {
|
||||||
|
result,
|
||||||
|
old_division: old_div,
|
||||||
|
new_division: new_div,
|
||||||
|
new_season_number: new_season,
|
||||||
|
coins_awarded: coins,
|
||||||
|
pack_awarded: pack_id.map(String::from),
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = fetch(pool, profile_id).await?.expect("season must exist");
|
||||||
|
Ok((updated, Some(summary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return past seasons for a profile, newest first (max 20).
|
||||||
|
pub async fn get_history(pool: &Pool, profile_id: &str) -> AppResult<Vec<SeasonHistoryEntry>> {
|
||||||
|
let rows = sqlx::query_as::<_, SeasonHistoryEntry>(
|
||||||
|
"SELECT id, profile_id, season_number, division, season_points, wins, draws, losses, \
|
||||||
|
result, new_division, coins_awarded, pack_awarded, ended_at \
|
||||||
|
FROM season_history WHERE profile_id = ? ORDER BY season_number DESC LIMIT 20",
|
||||||
|
)
|
||||||
|
.bind(profile_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
+976
-88
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,125 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::card::OwnedCard,
|
||||||
|
models::chemistry_style::ChemistryStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OWNED_CARD_SELECT: &str =
|
||||||
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
chemistry_style, position_override, training_bonus \
|
||||||
|
FROM owned_cards";
|
||||||
|
|
||||||
|
pub const MAX_TRAINING_BONUS: i64 = 3;
|
||||||
|
|
||||||
|
/// Cost in coins to change a player's position.
|
||||||
|
pub const POSITION_CHANGE_COST: i64 = 500;
|
||||||
|
|
||||||
|
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
||||||
|
sqlx::query_as::<_, OwnedCard>(&format!(
|
||||||
|
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||||
|
))
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a chemistry style to an owned card.
|
||||||
|
///
|
||||||
|
/// The style is validated against the loaded definitions. The card is not
|
||||||
|
/// mutated in memory — callers should re-fetch if they need the updated state.
|
||||||
|
pub async fn apply_chemistry_style(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
style_id: &str,
|
||||||
|
styles: &[ChemistryStyle],
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
// Validate style exists
|
||||||
|
if !styles.iter().any(|s| s.id == style_id) {
|
||||||
|
return Err(AppError::NotFound(format!(
|
||||||
|
"chemistry style '{style_id}' not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET chemistry_style = ? WHERE id = ?")
|
||||||
|
.bind(style_id)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override a player's position. Costs POSITION_CHANGE_COST coins.
|
||||||
|
pub async fn change_position(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
new_position: &str,
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
let valid_positions = [
|
||||||
|
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
|
||||||
|
"CF", "ST",
|
||||||
|
];
|
||||||
|
if !valid_positions.contains(&new_position) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"unknown position '{new_position}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
// Deduct coins
|
||||||
|
crate::services::club::spend_coins(pool, club_id, POSITION_CHANGE_COST).await?;
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET position_override = ? WHERE id = ?")
|
||||||
|
.bind(new_position)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a training boost to an owned card.
|
||||||
|
///
|
||||||
|
/// `boost` is the number of OVR points to add (1–3).
|
||||||
|
/// The total training_bonus is capped at MAX_TRAINING_BONUS.
|
||||||
|
/// Training is free — the "cost" is consuming a training card item, which is
|
||||||
|
/// handled at the route layer (future: deduct a training_card from inventory).
|
||||||
|
pub async fn apply_training(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
owned_card_id: &str,
|
||||||
|
boost: i64,
|
||||||
|
) -> AppResult<OwnedCard> {
|
||||||
|
if !(1..=3).contains(&boost) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"training boost must be between 1 and 3".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let card = fetch_owned(pool, owned_card_id, club_id).await?;
|
||||||
|
|
||||||
|
let new_bonus = (card.training_bonus + boost).min(MAX_TRAINING_BONUS);
|
||||||
|
if new_bonus == card.training_bonus {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"card has already reached the maximum training bonus of +{MAX_TRAINING_BONUS}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE owned_cards SET training_bonus = ? WHERE id = ?")
|
||||||
|
.bind(new_bonus)
|
||||||
|
.bind(owned_card_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
fetch_owned(pool, owned_card_id, club_id).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Reproduction for the fresh-DB multi-connection warm-up write failure.
|
||||||
|
//! Forces several pooled connections to open concurrently on a brand-new DB and
|
||||||
|
//! captures the ACTUAL sqlx/SQLite error (not the service's generic string).
|
||||||
|
|
||||||
|
use openfut_core::db::{init_pool, run_migrations};
|
||||||
|
use openfut_core::services::economy;
|
||||||
|
|
||||||
|
async fn seed_club(pool: &sqlx::SqlitePool) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p','t','t')",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('c','p','c',100000,'t','t')")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||||
|
async fn fresh_db_multiconn_concurrent_writes() {
|
||||||
|
let base = std::env::temp_dir().join(format!("ofut-cc-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let iters = 100usize;
|
||||||
|
let mut failures = 0usize;
|
||||||
|
let mut first_err = String::new();
|
||||||
|
for i in 0..iters {
|
||||||
|
let url = format!("sqlite://{}/db{i}.db", base.display());
|
||||||
|
let pool = init_pool(&url, 5).await.expect("init_pool");
|
||||||
|
run_migrations(&pool).await.expect("migrations");
|
||||||
|
seed_club(&pool).await;
|
||||||
|
// Fire concurrent credits to force several connections to warm up at once
|
||||||
|
// on the brand-new DB, then a write — the harness's failing shape.
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let p = pool.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
economy::grant_reward(&p, "c", 1).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
match h.await.unwrap() {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
failures += 1;
|
||||||
|
if first_err.is_empty() {
|
||||||
|
first_err = format!("{e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serialization correctness: 8 concurrent +1 credits, no lost update.
|
||||||
|
let bal = economy::balance(&pool, "c").await.unwrap();
|
||||||
|
assert_eq!(bal, 100_008, "iter {i}: lost update under concurrency");
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
assert_eq!(
|
||||||
|
failures,
|
||||||
|
0,
|
||||||
|
"{failures}/{} iterations had a write failure; first error: {first_err}",
|
||||||
|
iters * 8
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Content preflight: a real profile with owned players but a missing
|
||||||
|
//! CardDefinition must fail startup LOUDLY, never serve a silent empty club.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use openfut_core::services::card_db::CardDb;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn fresh_pool() -> sqlx::SqlitePool {
|
||||||
|
let p = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&p)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_profile(app: &axum::Router) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/auth/local")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"username":"CAGE"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"auth/local should create a profile+club"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_owned(pool: &sqlx::SqlitePool, id: &str, club_id: &str, card_id: &str) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(card_id)
|
||||||
|
.bind("2026-01-01T00:00:00Z")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn preflight_fails_on_owned_card_missing_definition() {
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
// first build is fine: no owned cards yet.
|
||||||
|
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
|
||||||
|
create_profile(&app).await;
|
||||||
|
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_owned(&pool, "oc-bogus", &club, "fifa17_definitely_missing_999999").await;
|
||||||
|
|
||||||
|
// second build must now fail preflight: one owned card references a def that
|
||||||
|
// is not loaded — must not silently serve an empty collection.
|
||||||
|
let err = openfut_core::build_app(pool.clone(), "data")
|
||||||
|
.await
|
||||||
|
.expect_err("preflight must fail on a missing definition");
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("content preflight failed"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn preflight_passes_when_owned_card_definition_is_loaded() {
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
// pick a definition that IS in the default data/cards catalog.
|
||||||
|
let valid_id = CardDb::load("data")
|
||||||
|
.unwrap()
|
||||||
|
.all()
|
||||||
|
.first()
|
||||||
|
.map(|c| c.id.clone())
|
||||||
|
.expect("data/cards must be non-empty");
|
||||||
|
|
||||||
|
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
|
||||||
|
create_profile(&app).await;
|
||||||
|
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_owned(&pool, "oc-valid", &club, &valid_id).await;
|
||||||
|
|
||||||
|
let _app = openfut_core::build_app(pool.clone(), "data")
|
||||||
|
.await
|
||||||
|
.expect("preflight passes when the owned card's definition is loaded");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user