feat(dev): add the local profile with recorded third-party answers

Profile "local" points Steam, Twitch/IGDB and speedrun.com to a WireMock
container from compose.yaml (recordings in local/wiremock) and checks
logins against the local Auth.

The IGDB lookups now keep only the ids they asked for: an answer with
another game made tags and release years drop the whole batch.
This commit is contained in:
lobo committed 2026-09-28 13:07:40 +02:00
1 parent 5a089b2082
commit 5c0c0c6a78
54 files changed
+1539 -8

No files matched your search

+10 -1
View File
@@ -7,4 +7,13 @@ services:
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=local_secret_password
- POSTGRES_PASSWORD=local_secret_password
# Recorded answers of Steam, Twitch/IGDB and speedrun.com (local/wiremock). Starts only with the local profile.
wiremock:
image: wiremock/wiremock:3.9.1
container_name: tbp_core_mocks_local
profiles: ["local"]
ports:
- "8089:8080"
volumes:
- ./local/wiremock:/home/wiremock
@@ -0,0 +1,34 @@
[
{
"game": 72,
"uid": "620"
},
{
"game": 113112,
"uid": "1145360"
},
{
"game": 17000,
"uid": "413150"
},
{
"game": 7046,
"uid": "427520"
},
{
"game": 26226,
"uid": "504230"
},
{
"game": 14593,
"uid": "367520"
},
{
"game": 233,
"uid": "220"
},
{
"game": 133236,
"uid": "892970"
}
]
@@ -0,0 +1,27 @@
[
{
"id": 113112,
"name": "Hades",
"first_release_date": 1600387200,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1500,
"external_games": [
{
"external_game_source": 1,
"uid": "1145360"
}
]
}
]
@@ -0,0 +1,17 @@
[
{
"id": 119388,
"name": "The Legend of Zelda: Tears of the Kingdom",
"first_release_date": 1683849600,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 900
}
]
@@ -0,0 +1,27 @@
[
{
"id": 26226,
"name": "Celeste",
"first_release_date": 1516838400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1300,
"external_games": [
{
"external_game_source": 1,
"uid": "504230"
}
]
}
]
@@ -0,0 +1,67 @@
[
{
"id": 119388,
"name": "The Legend of Zelda: Tears of the Kingdom",
"first_release_date": 1683849600,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 900
},
{
"id": 113112,
"name": "Hades",
"first_release_date": 1600387200,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1500,
"external_games": [
{
"external_game_source": 1,
"uid": "1145360"
}
]
},
{
"id": 26226,
"name": "Celeste",
"first_release_date": 1516838400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1300,
"external_games": [
{
"external_game_source": 1,
"uid": "504230"
}
]
}
]
@@ -0,0 +1,38 @@
[
{
"id": 72,
"first_release_date": 1303171200
},
{
"id": 113112,
"first_release_date": 1600387200
},
{
"id": 17000,
"first_release_date": 1456444800
},
{
"id": 7046,
"first_release_date": 1600387200
},
{
"id": 26226,
"first_release_date": 1516838400
},
{
"id": 14593,
"first_release_date": 1487980800
},
{
"id": 233,
"first_release_date": 1100736000
},
{
"id": 133236,
"first_release_date": 1612828800
},
{
"id": 119388,
"first_release_date": 1683849600
}
]
+257
View File
@@ -0,0 +1,257 @@
[
{
"id": 72,
"genres": [
{
"name": "Puzzle"
},
{
"name": "Platform"
},
{
"name": "Shooter"
}
],
"themes": [
{
"name": "Science fiction"
},
{
"name": "Comedy"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Co-operative"
}
]
},
{
"id": 113112,
"genres": [
{
"name": "Role-playing (RPG)"
},
{
"name": "Hack and slash/Beat 'em up"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 17000,
"genres": [
{
"name": "Role-playing (RPG)"
},
{
"name": "Simulator"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Sandbox"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
},
{
"name": "Co-operative"
}
]
},
{
"id": 7046,
"genres": [
{
"name": "Simulator"
},
{
"name": "Strategy"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Science fiction"
},
{
"name": "Sandbox"
},
{
"name": "Survival"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
}
]
},
{
"id": 26226,
"genres": [
{
"name": "Platform"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 14593,
"genres": [
{
"name": "Platform"
},
{
"name": "Adventure"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 233,
"genres": [
{
"name": "Shooter"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Science fiction"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 133236,
"genres": [
{
"name": "Adventure"
},
{
"name": "Indie"
},
{
"name": "Simulator"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
},
{
"name": "Sandbox"
},
{
"name": "Survival"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
},
{
"name": "Co-operative"
}
]
},
{
"id": 119388,
"genres": [
{
"name": "Adventure"
},
{
"name": "Role-playing (RPG)"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
},
{
"name": "Open world"
}
],
"game_modes": [
{
"name": "Single player"
}
]
}
]
@@ -0,0 +1,58 @@
[
{
"game_id": 72,
"hastily": 21600,
"normally": 32400,
"completely": 79200,
"count": 100
},
{
"game_id": 113112,
"hastily": 72000,
"normally": 90000,
"completely": 342000,
"count": 100
},
{
"game_id": 17000,
"hastily": 144000,
"normally": 190800,
"completely": 576000,
"count": 100
},
{
"game_id": 7046,
"hastily": 108000,
"normally": 216000,
"completely": 900000,
"count": 100
},
{
"game_id": 26226,
"hastily": 28800,
"normally": 43200,
"completely": 144000,
"count": 100
},
{
"game_id": 14593,
"hastily": 97200,
"normally": 147600,
"completely": 226800,
"count": 100
},
{
"game_id": 233,
"hastily": 43200,
"normally": 50400,
"completely": 68400,
"count": 100
},
{
"game_id": 119388,
"hastily": 180000,
"normally": 216000,
"completely": 684000,
"count": 100
}
]
@@ -0,0 +1,17 @@
{
"data": [
{
"id": "o1y9j9v6",
"names": {
"international": "Celeste"
},
"released": 2018,
"weblink": "https://www.speedrun.com/celeste",
"links": [
{
"rel": "self"
}
]
}
]
}
@@ -0,0 +1,7 @@
{
"data": [
{
"id": "y8dwozoj"
}
]
}
@@ -0,0 +1,22 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "FIRST_STEPS",
"percent": 88.0
},
{
"name": "CRYSTAL_HEART",
"percent": 35.2
},
{
"name": "SUMMIT",
"percent": 30.6
},
{
"name": "FAREWELL",
"percent": 5.9
}
]
}
}
@@ -0,0 +1,22 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_WAKE_UP",
"percent": 92.1
},
{
"name": "ACH_SURVIVE_CONTAINMENT",
"percent": 70.4
},
{
"name": "ACH_CO_OP",
"percent": 22.5
},
{
"name": "ACH_END_SP",
"percent": 48.3
}
]
}
}
@@ -0,0 +1,71 @@
{
"response": {
"game_count": 8,
"games": [
{
"appid": 620,
"name": "Portal 2",
"playtime_forever": 1450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1145360,
"name": "Hades",
"playtime_forever": 3200,
"playtime_2weeks": 240,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 413150,
"name": "Stardew Valley",
"playtime_forever": 5400,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 427520,
"name": "Factorio",
"playtime_forever": 12000,
"playtime_2weeks": 600,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 504230,
"name": "Celeste",
"playtime_forever": 800,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 367520,
"name": "Hollow Knight",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 220,
"name": "Half-Life 2",
"playtime_forever": 780,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 892970,
"name": "Valheim",
"playtime_forever": 90,
"playtime_2weeks": 90,
"img_icon_url": "",
"has_community_visible_stats": true
}
]
}
}
@@ -0,0 +1,29 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Celeste",
"success": true,
"achievements": [
{
"apiname": "FIRST_STEPS",
"achieved": 1,
"unlocktime": 1700000000
},
{
"apiname": "CRYSTAL_HEART",
"achieved": 1,
"unlocktime": 1700003600
},
{
"apiname": "SUMMIT",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "FAREWELL",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,29 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Portal 2",
"success": true,
"achievements": [
{
"apiname": "ACH_WAKE_UP",
"achieved": 1,
"unlocktime": 1700000000
},
{
"apiname": "ACH_SURVIVE_CONTAINMENT",
"achieved": 1,
"unlocktime": 1700003600
},
{
"apiname": "ACH_CO_OP",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_END_SP",
"achieved": 1,
"unlocktime": 1700010800
}
]
}
}
@@ -0,0 +1,12 @@
{
"response": {
"players": [
{
"steamid": "76561198000000001",
"personaname": "Dev",
"profileurl": "https://steamcommunity.com/profiles/76561198000000001/",
"avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg"
}
]
}
}
@@ -0,0 +1,42 @@
{
"game": {
"gameName": "Celeste",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "FIRST_STEPS",
"displayName": "First Steps",
"description": "Complete the Prologue.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "CRYSTAL_HEART",
"displayName": "Heart of the Mountain",
"description": "Collect a Crystal Heart.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "SUMMIT",
"displayName": "Summit",
"description": "Reach the summit and finish the story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "FAREWELL",
"displayName": "Farewell",
"description": "Complete Chapter 9.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,42 @@
{
"game": {
"gameName": "Portal 2",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_WAKE_UP",
"displayName": "Wake Up Call",
"description": "Survive the manual override of the relaxation center.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_SURVIVE_CONTAINMENT",
"displayName": "Pit Boss",
"description": "Show that pit who is boss.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_CO_OP",
"displayName": "Team Building",
"description": "Complete all test chambers in the Team Building co-op course.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_END_SP",
"displayName": "Lunacy",
"description": "Complete the single player campaign.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,34 @@
{
"response": {
"store_items": [
{
"appid": 1030300,
"success": 1,
"name": "Hollow Knight: Silksong",
"best_purchase_option": {
"formatted_final_price": "19,50€",
"formatted_original_price": "19,50€",
"discount_pct": 0
},
"assets": {
"asset_url_format": "steam/apps/1030300/${FILENAME}",
"header": "header.jpg"
}
},
{
"appid": 1145350,
"success": 1,
"name": "Hades II",
"best_purchase_option": {
"formatted_final_price": "22,49€",
"formatted_original_price": "29,99€",
"discount_pct": 25
},
"assets": {
"asset_url_format": "steam/apps/1145350/${FILENAME}",
"header": "header.jpg"
}
}
]
}
}
@@ -0,0 +1,14 @@
{
"response": {
"items": [
{
"appid": 1030300,
"date_added": 1735689600
},
{
"appid": 1145350,
"date_added": 1735776000
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"access_token": "local",
"expires_in": 5000000,
"token_type": "bearer"
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/external_games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-external-games.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 113112;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-113112.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 119388;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-119388.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 26226;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-26226.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \""
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "fields first_release_date;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-years.json"
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
},
"priority": 10
}
+18
View File
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "genres.name"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-tags.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/game_time_to_beats"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-time-to-beats.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/games",
"queryParameters": {
"name": {
"equalTo": "Celeste"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "speedrun-games-celeste.json"
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"data": []
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/runs",
"queryParameters": {
"game": {
"equalTo": "o1y9j9v6"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "speedrun-runs-celeste.json"
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/runs"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"data": []
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"achievementpercentages": {
"achievements": []
}
}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IPlayerService/GetOwnedGames/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-owned-games.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-620.json"
}
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/"
},
"response": {
"status": 400,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"playerstats": {
"error": "Requested app has no stats",
"success": false
}
}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUser/GetPlayerSummaries/v0002/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-summary.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-620.json"
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/"
},
"response": {
"status": 400,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IStoreBrowseService/GetItems/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-store-items.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IWishlistService/GetWishlist/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-wishlist.json"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/twitch/oauth2/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "twitch-token.json"
}
}
+40 -1
View File
@@ -264,7 +264,46 @@ a second row when IGDB learns its Steam appid later and the user owns it on Stea
## Run locally
Needs Java 25 and Docker.
### Local development without keys (profile `local`)
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
other, then start them in this order (Core also starts without Auth and finds it later):
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
(IDE: active profile `local`). Port 8080.
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
What the `local` profile does:
- Spring Boot's Docker Compose support starts each service's `compose.yaml`: PostgreSQL for Auth (port 5432) and
for Core (port 5433), and in Core a WireMock container (port 8089, compose profile `local`) that answers in
place of Steam, Twitch/IGDB and speedrun.com from `ToBePlayed-Core/local/wiremock`.
- Auth makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this profile), so no
key is in the repo. After an Auth restart (also a devtools restart), log in again.
- Never use `local` on a server: it adds an admin with a public password. Auth refuses `local` together with
`prod`.
- Auth adds the dev user (Flyway callback in `db/local`): role `PLATFORM_ADMIN`, so user, auditor and admin pages
can all be tried ("View as" in the menu switches down), and a linked Steam ID that the recorded Steam answers
belong to. The invite code for more users is `local`.
- Core checks the login with Auth's local JWKS, like in production.
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
To change the recorded answers, edit the JSON in `local/wiremock/mappings` (which request) and
`local/wiremock/__files` (the answer), then restart Core (it restarts the container). `LocalMocksParseTest` checks
that every answer still fits the adapters' response types. `http://localhost:8089/__admin/requests` lists what Core
asked.
### With the real services (profile `dev`)
Needs Java 25, Docker and your own keys.
```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
@@ -20,6 +20,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -275,13 +276,14 @@ public class IgdbApiService {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, TimeToBeat> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream()
.map(String::valueOf).collect(Collectors.joining(","));
List<Long> batchIds = ids.subList(from, Math.min(from + BATCH, ids.size()));
Set<Long> batch = new HashSet<>(batchIds);
String gameIds = batchIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.TimeToBeat[].class);
for (IgdbResponses.TimeToBeat time : times) {
if (time.gameId() != null) {
if (time.gameId() != null && batch.contains(time.gameId())) {
result.putIfAbsent(time.gameId(),
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())));
}
@@ -353,8 +355,10 @@ public class IgdbApiService {
"fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.GameTags[].class);
Map<Long, GameTags> result = new HashMap<>();
// Only the asked ids, in this and the other lookups: callers index their maps by them, and an answer can
// hold more games.
for (IgdbResponses.GameTags game : games) {
if (game.id() != null) {
if (game.id() != null && igdbIds.contains(game.id())) {
result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes())));
}
}
@@ -371,7 +375,7 @@ public class IgdbApiService {
IgdbResponses.Game[].class);
Map<Long, Integer> result = new HashMap<>();
for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null) {
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
result.put(game.id(), year(game.firstReleaseDate()));
}
}
@@ -397,7 +401,8 @@ public class IgdbApiService {
IgdbResponses.ExternalGame[].class);
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
for (IgdbResponses.ExternalGame external : externals) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")
&& steamAppids.contains(Long.parseLong(external.uid()))) {
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
}
}
+31
View File
@@ -0,0 +1,31 @@
# Local development without keys or third parties: start with the profile "local" (alone), after Auth's local
# profile. Postgres and WireMock (local/wiremock) come from compose.yaml. See the readme, "Run locally".
spring:
docker:
compose:
profiles:
active: local
# Also when Postgres still runs from a dev run; else WireMock would not start.
start:
skip: never
security:
jwt:
jwks-url: "http://localhost:8080/.well-known/jwks.json"
app:
cors:
allowed-origins: "http://localhost:5173"
steam:
api-key: local
base-url: "http://localhost:8089/steam"
igdb:
client-id: local
client-secret: local
base-url: "http://localhost:8089/igdb"
token-base-url: "http://localhost:8089/twitch"
speedrun:
base-url: "http://localhost:8089/speedrun"
library:
# The image repair checks the real Steam CDN.
image-repair-cron: "-"
@@ -0,0 +1,104 @@
package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The recorded answers of the local profile (local/wiremock) still fit the adapters' response types.
*/
class LocalMocksParseTest extends AbstractUnitTest {
private static final Path FILES = Path.of("local/wiremock/__files");
private static final Path MAPPINGS = Path.of("local/wiremock/mappings");
// By file name prefix; no prefix starts another one.
private static final Map<String, Class<?>> TYPES = Map.ofEntries(
Map.entry("steam-owned-games", SteamOwnedGamesResponse.class),
Map.entry("steam-player-summary", SteamPlayerSummaryResponse.class),
Map.entry("steam-wishlist", SteamWishlistResponse.class),
Map.entry("steam-store-items", SteamStoreItemsResponse.class),
Map.entry("steam-schema-", SteamSchemaForGameResponse.class),
Map.entry("steam-global-percentages-", SteamGlobalAchievementPercentagesResponse.class),
Map.entry("steam-player-achievements-", SteamPlayerAchievementsResponse.class),
Map.entry("twitch-token", IgdbResponses.Token.class),
Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class),
Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class),
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
private final JsonMapper jsonMapper = JsonMapper.builder().build();
@Test
void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings;
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
assertThat(response.has("bodyFileName") || response.has("jsonBody")).as(mapping.toString()).isTrue();
if (response.has("bodyFileName")) {
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
}
}
}
// Every top-level field of an answer, and the first field (the id) of every list element, is not null.
private static void assertFilled(String name, Object parsed) throws ReflectiveOperationException {
if (parsed instanceof Object[] elements) {
assertThat(elements).as(name).isNotEmpty();
for (Object element : elements) {
RecordComponent id = element.getClass().getRecordComponents()[0];
assertThat(id.getAccessor().invoke(element)).as(name + " " + id.getName()).isNotNull();
}
return;
}
for (RecordComponent component : parsed.getClass().getRecordComponents()) {
assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull();
}
}
@Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files;
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
assertThat(files).isNotEmpty();
for (Path file : files) {
String name = file.getFileName().toString();
Class<?> type = TYPES.entrySet().stream()
.filter(entry -> name.startsWith(entry.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElseThrow(() -> new AssertionError("No response type for " + name));
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
}
}
}
@@ -127,6 +127,38 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(tags.get(620L).themes()).contains("Horror");
}
@Test
void getTimeToBeat_AnswersForAppsAndGamesNotAskedFor_AreLeftOut() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 73, \"uid\": \"999\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 3600}, {\"game_id\": 73, \"normally\": 7200}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"game_id\": 1025, \"normally\": 3600}, {\"game_id\": 1026, \"normally\": 7200}]",
MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow()).containsOnlyKeys(620L);
assertThat(service.getTimeToBeatByGameIds(List.of(1025L)).orElseThrow()).containsOnlyKeys(1025L);
}
@Test
void getGameTagsAndYears_AnswerWithAGameNotAskedFor_KeepTheAskedGames() {
expectToken();
for (int call = 0; call < 2; call++) {
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72, \"first_release_date\": 1149120000, \"themes\": [{\"name\": \"Horror\"}]},"
+ " {\"id\": 999, \"first_release_date\": 1493942400}]", MediaType.APPLICATION_JSON));
}
assertThat(service.getGameTags(List.of(620L)).orElseThrow().get(620L).themes()).contains("Horror");
assertThat(service.getReleaseYears(List.of(620L))).contains(Map.of(620L, Set.of(2006)));
}
@Test
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
expectToken();