Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd1eda0ec | ||
|
|
07d287cd43 | ||
|
|
5c0c0c6a78 |
No files matched your search
+10
-1
@@ -7,4 +7,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=tbp_core
|
- POSTGRES_DB=tbp_core
|
||||||
- POSTGRES_USER=tbp_user
|
- 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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"urlPath": "/twitch/oauth2/token"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "twitch-token.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,10 @@ To Be Played is split into three repositories:
|
|||||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||||
and average completion. Readable only
|
and average completion. Readable only
|
||||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||||
groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default.
|
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
||||||
|
default. A section the user
|
||||||
|
hides (the ticket's `sections` claim: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS`) is null and runs no
|
||||||
|
query; a ticket without the claim shows all.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
@@ -264,7 +267,46 @@ a second row when IGDB learns its Steam appid later and the user owns it on Stea
|
|||||||
|
|
||||||
## Run locally
|
## 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
|
```bash
|
||||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Set<String> names(List<?> claim) {
|
||||||
|
return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
||||||
try {
|
try {
|
||||||
@@ -75,10 +79,9 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
}
|
}
|
||||||
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
||||||
// A ticket of an Auth from before the claim has none: then the profile shows no stats.
|
// A ticket of an Auth from before the claim has none: then the profile shows no stats.
|
||||||
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups
|
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
|
||||||
? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet())
|
Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null;
|
||||||
: Set.of();
|
return Optional.of(new ProfileTicket(userId, profileStats, sections));
|
||||||
return Optional.of(new ProfileTicket(userId, profileStats));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import java.util.Arrays;
|
|||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -275,13 +276,14 @@ public class IgdbApiService {
|
|||||||
List<Long> ids = List.copyOf(igdbIds);
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
Map<Long, TimeToBeat> result = new HashMap<>();
|
Map<Long, TimeToBeat> result = new HashMap<>();
|
||||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream()
|
List<Long> batchIds = ids.subList(from, Math.min(from + BATCH, ids.size()));
|
||||||
.map(String::valueOf).collect(Collectors.joining(","));
|
Set<Long> batch = new HashSet<>(batchIds);
|
||||||
|
String gameIds = batchIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||||
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
|
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
|
||||||
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
|
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||||
IgdbResponses.TimeToBeat[].class);
|
IgdbResponses.TimeToBeat[].class);
|
||||||
for (IgdbResponses.TimeToBeat time : times) {
|
for (IgdbResponses.TimeToBeat time : times) {
|
||||||
if (time.gameId() != null) {
|
if (time.gameId() != null && batch.contains(time.gameId())) {
|
||||||
result.putIfAbsent(time.gameId(),
|
result.putIfAbsent(time.gameId(),
|
||||||
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())));
|
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 + ";",
|
"fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||||
IgdbResponses.GameTags[].class);
|
IgdbResponses.GameTags[].class);
|
||||||
Map<Long, GameTags> result = new HashMap<>();
|
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) {
|
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())));
|
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);
|
IgdbResponses.Game[].class);
|
||||||
Map<Long, Integer> result = new HashMap<>();
|
Map<Long, Integer> result = new HashMap<>();
|
||||||
for (IgdbResponses.Game game : games) {
|
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()));
|
result.put(game.id(), year(game.firstReleaseDate()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +401,8 @@ public class IgdbApiService {
|
|||||||
IgdbResponses.ExternalGame[].class);
|
IgdbResponses.ExternalGame[].class);
|
||||||
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
|
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
|
||||||
for (IgdbResponses.ExternalGame external : externals) {
|
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()));
|
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class PublicProfileController {
|
|||||||
@GetMapping("/library")
|
@GetMapping("/library")
|
||||||
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
|
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
|
||||||
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
||||||
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats())))
|
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
|
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a public profile shows. Never notes, priority or anything that identifies the Steam account.
|
* What a public profile shows. Never notes, priority or anything that identifies the Steam account. The fields of a
|
||||||
|
* section the user hides ({@link core_service.modules.library.domain.ProfileSection}) are null.
|
||||||
*
|
*
|
||||||
|
* @param finishedGames FINISHED and COMPLETED; part of the summary, so it stays when the status counts are hidden
|
||||||
* @param byStatus has every status, also those with 0 games
|
* @param byStatus has every status, also those with 0 games
|
||||||
* @param playing up to 8 games, most played first
|
* @param playing up to 8 games, most played first
|
||||||
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
||||||
@@ -15,12 +17,13 @@ import java.util.Map;
|
|||||||
* @param stats only the stat groups the user shares, the others null; null when the user shares none
|
* @param stats only the stat groups the user shares, the others null; null when the user shares none
|
||||||
*/
|
*/
|
||||||
public record PublicLibraryDto(
|
public record PublicLibraryDto(
|
||||||
long totalGames,
|
Long totalGames,
|
||||||
long totalPlaytimeMinutes,
|
Long totalPlaytimeMinutes,
|
||||||
|
Long finishedGames,
|
||||||
Map<GameStatus, Long> byStatus,
|
Map<GameStatus, Long> byStatus,
|
||||||
List<Game> playing,
|
List<Game> playing,
|
||||||
List<Game> finished,
|
List<Game> finished,
|
||||||
int perfectGames,
|
Integer perfectGames,
|
||||||
Double averageCompletion,
|
Double averageCompletion,
|
||||||
UserStatsDto stats
|
UserStatsDto stats
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of the public profile a user can hide. Core sends no data for a hidden part.
|
||||||
|
*/
|
||||||
|
public enum ProfileSection {
|
||||||
|
SUMMARY, STATUS, PLAYING, FINISHED, STATS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param names from the profile ticket; null (a ticket from before the sections) shows all. Unknown names are
|
||||||
|
* left out.
|
||||||
|
*/
|
||||||
|
public static Set<ProfileSection> shown(Collection<String> names) {
|
||||||
|
Set<ProfileSection> sections = EnumSet.noneOf(ProfileSection.class);
|
||||||
|
for (ProfileSection section : values()) {
|
||||||
|
if (names == null || names.contains(section.name())) {
|
||||||
|
sections.add(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import core_service.modules.library.domain.GameSource;
|
|||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
|
import core_service.modules.library.domain.ProfileSection;
|
||||||
import core_service.modules.library.domain.StatGroup;
|
import core_service.modules.library.domain.StatGroup;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
@@ -47,6 +48,7 @@ import java.util.Collection;
|
|||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -212,27 +214,45 @@ public class LibraryQueryService {
|
|||||||
* @param profileStats the stat group names of the profile ticket
|
* @param profileStats the stat group names of the profile ticket
|
||||||
*/
|
*/
|
||||||
@Public
|
@Public
|
||||||
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
|
/**
|
||||||
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
|
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
|
||||||
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
|
*/
|
||||||
List.of(GameStatus.PLAYING),
|
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
|
||||||
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
||||||
List<UserGame> finished = userGameRepository.findListedByStatusIn(userId,
|
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
||||||
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
Map<GameStatus, Long> byStatus = summary || sections.contains(ProfileSection.STATUS)
|
||||||
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
|
? userGameRepository.countEveryStatus(userId) : null;
|
||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
|
List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
|
||||||
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
|
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
|
||||||
|
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))))
|
||||||
|
: null;
|
||||||
|
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
|
||||||
|
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
||||||
|
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))))
|
||||||
|
: null;
|
||||||
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished)
|
||||||
|
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList())
|
||||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
List<AchievementProgress> started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null;
|
||||||
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
|
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
|
||||||
Set<StatGroup> shared = StatGroup.shared(profileStats);
|
return new PublicLibraryDto(
|
||||||
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null,
|
||||||
|
summary ? userGameRepository.sumPlaytime(userId) : null,
|
||||||
|
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
|
||||||
|
sections.contains(ProfileSection.STATUS) ? byStatus : null,
|
||||||
toPublicGames(playing, meta), toPublicGames(finished, meta),
|
toPublicGames(playing, meta), toPublicGames(finished, meta),
|
||||||
perfectGames(started), averageCompletion(started),
|
summary ? perfectGames(started) : null,
|
||||||
|
summary ? averageCompletion(started) : null,
|
||||||
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null for a hidden section
|
||||||
|
*/
|
||||||
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
|
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
|
||||||
|
if (games == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return games.stream()
|
return games.stream()
|
||||||
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
||||||
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ import java.util.Set;
|
|||||||
* A valid public-profile ticket from Auth.
|
* A valid public-profile ticket from Auth.
|
||||||
*
|
*
|
||||||
* @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them
|
* @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them
|
||||||
|
* @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all
|
||||||
*/
|
*/
|
||||||
public record ProfileTicket(long userId, Set<String> profileStats) {}
|
public record ProfileTicket(long userId, Set<String> profileStats, Set<String> sections) {}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyProfileTicket_ReturnsTheUser() {
|
void verifyProfileTicket_ReturnsTheUser() {
|
||||||
assertEquals(Optional.of(new ProfileTicket(42L, Set.of())),
|
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
|
||||||
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
|
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,16 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
.claim("profileStats", List.of("PILE", "TREND"))
|
.claim("profileStats", List.of("PILE", "TREND"))
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket));
|
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyProfileTicket_ReadsTheShownSections() {
|
||||||
|
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
|
||||||
|
.claim("sections", List.of("PLAYING", "STATS"))
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
|
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -127,6 +127,38 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
assertThat(tags.get(620L).themes()).contains("Horror");
|
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
|
@Test
|
||||||
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
|
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
|
||||||
expectToken();
|
expectToken();
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String ticket(long userId, List<String> profileStats, List<String> sections) {
|
||||||
|
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
||||||
|
.claim("profileStats", profileStats).claim("sections", sections)
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||||
|
}
|
||||||
|
|
||||||
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
|
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
|
||||||
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
|
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
|
||||||
game.setStatus(status);
|
game.setStatus(status);
|
||||||
@@ -94,6 +100,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.totalGames").value(3))
|
.andExpect(jsonPath("$.totalGames").value(3))
|
||||||
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260))
|
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260))
|
||||||
|
.andExpect(jsonPath("$.finishedGames").value(2))
|
||||||
.andExpect(jsonPath("$.byStatus.FINISHED").value(1))
|
.andExpect(jsonPath("$.byStatus.FINISHED").value(1))
|
||||||
.andExpect(jsonPath("$.byStatus.COMPLETED").value(1))
|
.andExpect(jsonPath("$.byStatus.COMPLETED").value(1))
|
||||||
.andExpect(jsonPath("$.playing", hasSize(1)))
|
.andExpect(jsonPath("$.playing", hasSize(1)))
|
||||||
@@ -142,6 +149,41 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.stats.trend").value(nullValue()));
|
.andExpect(jsonPath("$.stats.trend").value(nullValue()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_HiddenSections_SendNoData() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library")
|
||||||
|
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES"), List.of("PLAYING"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.playing", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.totalGames").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.finishedGames").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.perfectGames").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.byStatus").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.finished").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.stats").value(nullValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_SummaryWithoutStatus_KeepsTheFinishedCountButNoStatusCounts() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library")
|
||||||
|
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("SUMMARY"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalGames").value(3))
|
||||||
|
.andExpect(jsonPath("$.finishedGames").value(2))
|
||||||
|
.andExpect(jsonPath("$.byStatus").value(nullValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_OnlyStatusShown_HasTheCountsButNoTotals() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library")
|
||||||
|
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("STATUS", "MONEY"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.byStatus.FINISHED").value(1))
|
||||||
|
.andExpect(jsonPath("$.totalGames").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.playing").value(nullValue()));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
||||||
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
||||||
|
|||||||
Reference in new issue
Block a user