Public Access
Compare commits
3
Commits
03469f9eb5
...
dba4fedfa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba4fedfa1 | ||
|
|
cdfbb4a863 | ||
|
|
c6bfea1976 |
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_B1",
|
||||||
|
"percent": 81.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B2",
|
||||||
|
"percent": 57.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B3",
|
||||||
|
"percent": 39.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B4",
|
||||||
|
"percent": 26.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B5",
|
||||||
|
"percent": 23.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B6",
|
||||||
|
"percent": 18.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_A1",
|
||||||
|
"percent": 88.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A2",
|
||||||
|
"percent": 52.7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A3",
|
||||||
|
"percent": 74.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A4",
|
||||||
|
"percent": 31.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A5",
|
||||||
|
"percent": 44.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A6",
|
||||||
|
"percent": 36.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A7",
|
||||||
|
"percent": 28.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A8",
|
||||||
|
"percent": 4.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_F1",
|
||||||
|
"percent": 91.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F2",
|
||||||
|
"percent": 64.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F3",
|
||||||
|
"percent": 48.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F4",
|
||||||
|
"percent": 37.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F5",
|
||||||
|
"percent": 22.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F6",
|
||||||
|
"percent": 11.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_E1",
|
||||||
|
"percent": 83.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E2",
|
||||||
|
"percent": 62.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E3",
|
||||||
|
"percent": 44.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E4",
|
||||||
|
"percent": 31.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E5",
|
||||||
|
"percent": 27.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E6",
|
||||||
|
"percent": 9.7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_C1",
|
||||||
|
"percent": 78.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C2",
|
||||||
|
"percent": 47.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C3",
|
||||||
|
"percent": 33.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C4",
|
||||||
|
"percent": 35.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C5",
|
||||||
|
"percent": 41.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C6",
|
||||||
|
"percent": 12.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"achievementpercentages": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_D1",
|
||||||
|
"percent": 70.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D2",
|
||||||
|
"percent": 25.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D3",
|
||||||
|
"percent": 30.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D4",
|
||||||
|
"percent": 15.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D5",
|
||||||
|
"percent": 12.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D6",
|
||||||
|
"percent": 8.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"game_count": 8,
|
"game_count": 30,
|
||||||
"games": [
|
"games": [
|
||||||
{
|
{
|
||||||
"appid": 620,
|
"appid": 620,
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"playtime_forever": 1450,
|
"playtime_forever": 1450,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1787300000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 1145360,
|
"appid": 1145360,
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"playtime_forever": 3200,
|
"playtime_forever": 3200,
|
||||||
"playtime_2weeks": 240,
|
"playtime_2weeks": 240,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790500000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 413150,
|
"appid": 413150,
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
"playtime_forever": 5400,
|
"playtime_forever": 5400,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1788900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 427520,
|
"appid": 427520,
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"playtime_forever": 12000,
|
"playtime_forever": 12000,
|
||||||
"playtime_2weeks": 600,
|
"playtime_2weeks": 600,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 504230,
|
"appid": 504230,
|
||||||
@@ -40,7 +44,8 @@
|
|||||||
"playtime_forever": 800,
|
"playtime_forever": 800,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1789200000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 367520,
|
"appid": 367520,
|
||||||
@@ -56,7 +61,8 @@
|
|||||||
"playtime_forever": 780,
|
"playtime_forever": 780,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1786900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 892970,
|
"appid": 892970,
|
||||||
@@ -64,6 +70,198 @@
|
|||||||
"playtime_forever": 90,
|
"playtime_forever": 90,
|
||||||
"playtime_2weeks": 90,
|
"playtime_2weeks": 90,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790700000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1245620,
|
||||||
|
"name": "ELDEN RING",
|
||||||
|
"playtime_forever": 6100,
|
||||||
|
"playtime_2weeks": 900,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1091500,
|
||||||
|
"name": "Cyberpunk 2077",
|
||||||
|
"playtime_forever": 4300,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1787900000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 292030,
|
||||||
|
"name": "The Witcher 3: Wild Hunt",
|
||||||
|
"playtime_forever": 5200,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1789800000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1086940,
|
||||||
|
"name": "Baldur's Gate 3",
|
||||||
|
"playtime_forever": 2800,
|
||||||
|
"playtime_2weeks": 300,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790150000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 730,
|
||||||
|
"name": "Counter-Strike 2",
|
||||||
|
"playtime_forever": 1900,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1788600000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 570,
|
||||||
|
"name": "Dota 2",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1174180,
|
||||||
|
"name": "Red Dead Redemption 2",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 105600,
|
||||||
|
"name": "Terraria",
|
||||||
|
"playtime_forever": 2600,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1789300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 294100,
|
||||||
|
"name": "RimWorld",
|
||||||
|
"playtime_forever": 3900,
|
||||||
|
"playtime_2weeks": 120,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 632470,
|
||||||
|
"name": "Disco Elysium - The Final Cut",
|
||||||
|
"playtime_forever": 1500,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1788100000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 548430,
|
||||||
|
"name": "Deep Rock Galactic",
|
||||||
|
"playtime_forever": 700,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 646570,
|
||||||
|
"name": "Slay the Spire",
|
||||||
|
"playtime_forever": 2200,
|
||||||
|
"playtime_2weeks": 60,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790450000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 2379780,
|
||||||
|
"name": "Balatro",
|
||||||
|
"playtime_forever": 1100,
|
||||||
|
"playtime_2weeks": 420,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790600000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1794680,
|
||||||
|
"name": "Vampire Survivors",
|
||||||
|
"playtime_forever": 450,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790700000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 268910,
|
||||||
|
"name": "Cuphead",
|
||||||
|
"playtime_forever": 900,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1787600000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 814380,
|
||||||
|
"name": "Sekiro: Shadows Die Twice",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 374320,
|
||||||
|
"name": "DARK SOULS III",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1057090,
|
||||||
|
"name": "Ori and the Will of the Wisps",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 275850,
|
||||||
|
"name": "No Man's Sky",
|
||||||
|
"playtime_forever": 120,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 526870,
|
||||||
|
"name": "Satisfactory",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 1332010,
|
||||||
|
"name": "Stray",
|
||||||
|
"playtime_forever": 330,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1789000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appid": 108600,
|
||||||
|
"name": "Project Zomboid",
|
||||||
|
"playtime_forever": 0,
|
||||||
|
"playtime_2weeks": null,
|
||||||
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "Cyberpunk 2077",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B3",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786518400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B4",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786777600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B5",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1787036800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_B6",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "ELDEN RING",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A3",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786518400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A4",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A5",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A6",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A7",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_A8",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "Stray",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F3",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F4",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F5",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_F6",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "Cuphead",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E3",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786518400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E4",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786777600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E5",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1787036800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_E6",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "Disco Elysium - The Final Cut",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C3",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786518400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C4",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786777600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C5",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1787036800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_C6",
|
||||||
|
"achieved": 0,
|
||||||
|
"unlocktime": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"playerstats": {
|
||||||
|
"steamID": "76561198000000001",
|
||||||
|
"gameName": "Slay the Spire",
|
||||||
|
"success": true,
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D1",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D2",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786259200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D3",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786518400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D4",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1786777600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D5",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1787036800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"apiname": "ACH_D6",
|
||||||
|
"achieved": 1,
|
||||||
|
"unlocktime": 1787296000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "Cyberpunk 2077",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_B1",
|
||||||
|
"displayName": "Street Kid",
|
||||||
|
"description": "Get through the opening job.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B2",
|
||||||
|
"displayName": "Chrome Fan",
|
||||||
|
"description": "Install five implants.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B3",
|
||||||
|
"displayName": "Fast Driver",
|
||||||
|
"description": "Win a street race.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B4",
|
||||||
|
"displayName": "Netrunner",
|
||||||
|
"description": "Hack ten enemies in one fight.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B5",
|
||||||
|
"displayName": "Final Run",
|
||||||
|
"description": "Complete the main story.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_B6",
|
||||||
|
"displayName": "Rich Merc",
|
||||||
|
"description": "Hold a big pile of eddies.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "ELDEN RING",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_A1",
|
||||||
|
"displayName": "Rune Bearer",
|
||||||
|
"description": "Pick up your first rune from the grace.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A2",
|
||||||
|
"displayName": "Margit Down",
|
||||||
|
"description": "Defeat the warden of the gate.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A3",
|
||||||
|
"displayName": "Roundtable Guest",
|
||||||
|
"description": "Visit the hold between worlds.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A4",
|
||||||
|
"displayName": "Smith Friend",
|
||||||
|
"description": "Upgrade a weapon to a high level.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A5",
|
||||||
|
"displayName": "Mounted Hunter",
|
||||||
|
"description": "Slay a foe from horseback.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A6",
|
||||||
|
"displayName": "Great Rune",
|
||||||
|
"description": "Restore a great rune.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A7",
|
||||||
|
"displayName": "Deep Delver",
|
||||||
|
"description": "Explore a deep underground city.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_A8",
|
||||||
|
"displayName": "Lord of Lands",
|
||||||
|
"description": "Gather every legendary talisman.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "Stray",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_F1",
|
||||||
|
"displayName": "Cat Nap",
|
||||||
|
"description": "Sleep in a sunny spot.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F2",
|
||||||
|
"displayName": "Rooftop Walker",
|
||||||
|
"description": "Cross the roofs of the old slum.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F3",
|
||||||
|
"displayName": "Robot Friend",
|
||||||
|
"description": "Make a friend of a small robot.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F4",
|
||||||
|
"displayName": "Meow Choir",
|
||||||
|
"description": "Meow in front of ten robots.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F5",
|
||||||
|
"displayName": "Fish Lover",
|
||||||
|
"description": "Find a cold bowl of fish.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_F6",
|
||||||
|
"displayName": "Secret Paw",
|
||||||
|
"description": "Open every hidden door.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "Cuphead",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_E1",
|
||||||
|
"displayName": "Contract Signed",
|
||||||
|
"description": "Take the deal at the casino.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E2",
|
||||||
|
"displayName": "Run and Gun",
|
||||||
|
"description": "Pass a platform stage.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E3",
|
||||||
|
"displayName": "Boss Rush",
|
||||||
|
"description": "Defeat five bosses.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E4",
|
||||||
|
"displayName": "Devil Beaten",
|
||||||
|
"description": "Complete the main story.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E5",
|
||||||
|
"displayName": "Coin Collector",
|
||||||
|
"description": "Find every coin of a world.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_E6",
|
||||||
|
"displayName": "Perfect Parry",
|
||||||
|
"description": "Parry twelve pink shots in a row.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "Disco Elysium - The Final Cut",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_C1",
|
||||||
|
"displayName": "Fresh Case",
|
||||||
|
"description": "Wake up in the hostel room.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C2",
|
||||||
|
"displayName": "Good Listener",
|
||||||
|
"description": "Talk to every skill voice.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C3",
|
||||||
|
"displayName": "Thought Cabinet",
|
||||||
|
"description": "Fill all slots of the cabinet.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C4",
|
||||||
|
"displayName": "Case Closed",
|
||||||
|
"description": "Finish the main story.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C5",
|
||||||
|
"displayName": "Best Friend",
|
||||||
|
"description": "Win the trust of your partner.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_C6",
|
||||||
|
"displayName": "Sweet Tunes",
|
||||||
|
"description": "Hear every song on the radio.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"game": {
|
||||||
|
"gameName": "Slay the Spire",
|
||||||
|
"gameVersion": "1",
|
||||||
|
"availableGameStats": {
|
||||||
|
"achievements": [
|
||||||
|
{
|
||||||
|
"name": "ACH_D1",
|
||||||
|
"displayName": "Spire Climber",
|
||||||
|
"description": "Reach the top of the first act.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D2",
|
||||||
|
"displayName": "Card Hoarder",
|
||||||
|
"description": "Own forty cards in one run.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D3",
|
||||||
|
"displayName": "Relic Fan",
|
||||||
|
"description": "Hold ten relics in one run.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D4",
|
||||||
|
"displayName": "Silent Hunter",
|
||||||
|
"description": "Win a run as the second hero.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D5",
|
||||||
|
"displayName": "Cold Heart",
|
||||||
|
"description": "Win a run as the third hero.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ACH_D6",
|
||||||
|
"displayName": "Pure Deck",
|
||||||
|
"description": "Win a run with a tiny deck.",
|
||||||
|
"hidden": 0,
|
||||||
|
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||||
|
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "1091500"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-1091500.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "1245620"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-1245620.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "1332010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-1332010.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "268910"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-268910.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "632470"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-632470.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"gameid": {
|
||||||
|
"equalTo": "646570"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-global-percentages-646570.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1091500"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-1091500.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1245620"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-1245620.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1332010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-1332010.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "268910"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-268910.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "632470"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-632470.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "646570"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-player-achievements-646570.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1091500"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-1091500.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1245620"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-1245620.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "1332010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-1332010.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "268910"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-268910.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "632470"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-632470.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||||
|
"queryParameters": {
|
||||||
|
"appid": {
|
||||||
|
"equalTo": "646570"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
"bodyFileName": "steam-schema-646570.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,11 @@ To Be Played is split into three repositories:
|
|||||||
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status
|
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status
|
||||||
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`).
|
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`).
|
||||||
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
|
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
|
||||||
deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
|
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
|
||||||
|
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
|
||||||
|
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
|
||||||
|
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
|
||||||
|
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
|
||||||
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
|
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
|
||||||
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
|
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
|
||||||
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
|
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
|
||||||
@@ -282,7 +286,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
|
| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
|
||||||
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
|
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
|
||||||
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
|
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
|
||||||
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
|
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
|
||||||
|
| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
|
||||||
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
|
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
@@ -338,8 +343,8 @@ flowchart LR
|
|||||||
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
|
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
|
||||||
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
|
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
|
||||||
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
|
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
|
||||||
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
|
Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
|
||||||
Inbox --> Db
|
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
@@ -350,7 +355,7 @@ flowchart LR
|
|||||||
| `stats` | Public numbers for the landing page |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
|
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
|
||||||
| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge |
|
| `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge |
|
||||||
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
||||||
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
|
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
|
||||||
|
|
||||||
private static final String SUMMARY = "SUMMARY";
|
static final String SUMMARY = "SUMMARY";
|
||||||
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
|
static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
|
||||||
|
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
|
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum.
|
||||||
|
*/
|
||||||
|
public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) {
|
||||||
|
|
||||||
|
public static final String NEW_GAME = "NEW_GAME";
|
||||||
|
public static final String ACHIEVEMENT = "ACHIEVEMENT";
|
||||||
|
public static final String STATUS = "STATUS";
|
||||||
|
public static final String WISHLIST = "WISHLIST";
|
||||||
|
|
||||||
|
public static DigestItem newGame(long appid, String gameName) {
|
||||||
|
return new DigestItem(NEW_GAME, appid, gameName, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem achievement(long appid, String gameName, String itemName) {
|
||||||
|
return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem status(long appid, String gameName, String from, String to) {
|
||||||
|
return new DigestItem(STATUS, appid, gameName, null, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem wishlist(long appid, String gameName) {
|
||||||
|
return new DigestItem(WISHLIST, appid, gameName, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind a {@link Digest}, in the order they are shown.
|
||||||
|
*/
|
||||||
|
public record DigestItems(List<DigestItem> items) {
|
||||||
|
|
||||||
|
public DigestItems plus(DigestItems other) {
|
||||||
|
List<DigestItem> all = new ArrayList<>(items);
|
||||||
|
all.addAll(other.items);
|
||||||
|
return new DigestItems(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same rule as {@link Digest#visibleOn}.
|
||||||
|
*
|
||||||
|
* @param sections the profile parts the user shows, null = all
|
||||||
|
*/
|
||||||
|
public DigestItems visibleOn(Collection<String> sections) {
|
||||||
|
if (sections == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
boolean summary = sections.contains(Digest.SUMMARY);
|
||||||
|
boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains);
|
||||||
|
return new DigestItems(items.stream().filter(item -> switch (item.kind()) {
|
||||||
|
case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary;
|
||||||
|
case DigestItem.STATUS -> status;
|
||||||
|
default -> true;
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps
|
||||||
|
* the place of the first move of its game.
|
||||||
|
*/
|
||||||
|
public DigestItems collapseStatus() {
|
||||||
|
Map<Long, String> lastTo = new HashMap<>();
|
||||||
|
for (DigestItem item : items) {
|
||||||
|
if (DigestItem.STATUS.equals(item.kind())) {
|
||||||
|
lastTo.put(item.appid(), item.toStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<DigestItem> result = new ArrayList<>();
|
||||||
|
Set<Long> seen = new HashSet<>();
|
||||||
|
for (DigestItem item : items) {
|
||||||
|
if (!DigestItem.STATUS.equals(item.kind())) {
|
||||||
|
result.add(item);
|
||||||
|
} else if (seen.add(item.appid())) {
|
||||||
|
String to = lastTo.get(item.appid());
|
||||||
|
if (!Objects.equals(item.fromStatus(), to)) {
|
||||||
|
result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DigestItems(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,19 @@ package core_service.modules.follow.repo;
|
|||||||
|
|
||||||
import core_service.modules.follow.domain.Cursor;
|
import core_service.modules.follow.domain.Cursor;
|
||||||
import core_service.modules.follow.domain.Digest;
|
import core_service.modules.follow.domain.Digest;
|
||||||
|
import core_service.modules.follow.domain.DigestItem;
|
||||||
|
import core_service.modules.follow.domain.DigestItems;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.sql.Array;
|
import java.sql.Array;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ import java.util.Optional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class FollowDigestRepository {
|
public class FollowDigestRepository {
|
||||||
|
|
||||||
|
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +89,17 @@ public class FollowDigestRepository {
|
|||||||
return max != null ? max : 0;
|
return max != null ? max : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest hand change after {@code afterId}, or {@code afterId} when there is none. A hand-only digest stops
|
||||||
|
* there, so the status rows of a sync stay for the digest of that sync.
|
||||||
|
*/
|
||||||
|
public long maxHandStatusChangeId(long userId, long afterId) {
|
||||||
|
Long max = jdbc.queryForObject("select max(id) from user_game_status_change "
|
||||||
|
+ "where user_id = :userId and by_user and id > :afterId",
|
||||||
|
user(userId).addValue("afterId", afterId), Long.class);
|
||||||
|
return max != null ? max : afterId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The changes the user made by hand: no old status = a game added by hand, else a status change.
|
* The changes the user made by hand: no old status = a game added by hand, else a status change.
|
||||||
*/
|
*/
|
||||||
@@ -98,6 +115,90 @@ public class FollowDigestRepository {
|
|||||||
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
|
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
|
||||||
|
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
|
||||||
|
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
|
||||||
|
*/
|
||||||
|
public DigestItems findSyncItems(long userId) {
|
||||||
|
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
|
||||||
|
items.addAll(findSyncAchievements(userId));
|
||||||
|
return new DigestItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DigestItem> findSyncNewGames(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select distinct c.appid, sg.name from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DigestItem> findSyncAchievements(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
with gained as (
|
||||||
|
select c.appid, sum(c.amount) as amount from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
group by c.appid),
|
||||||
|
unlocks as (
|
||||||
|
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
|
||||||
|
from user_game_achievement a
|
||||||
|
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
|
||||||
|
where a.user_id = :userId and a.unlocked
|
||||||
|
union all
|
||||||
|
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
|
||||||
|
from user_psn_trophy ut
|
||||||
|
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
|
||||||
|
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
|
||||||
|
where ut.user_id = :userId),
|
||||||
|
ranked as (
|
||||||
|
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
|
||||||
|
from unlocks u join gained g on g.appid = u.appid)
|
||||||
|
select r.appid, sg.name as game_name, r.name from ranked r
|
||||||
|
join gained g on g.appid = r.appid
|
||||||
|
left join steam_game sg on sg.appid = r.appid
|
||||||
|
where r.rn <= least(g.amount, :cap)
|
||||||
|
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
|
||||||
|
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
|
||||||
|
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
|
||||||
|
*/
|
||||||
|
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
|
||||||
|
return new DigestItems(jdbc.query("""
|
||||||
|
select c.appid, sg.name, c.from_status, c.to_status
|
||||||
|
from user_game_status_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
|
||||||
|
and (c.by_user or (:withSyncMoves and c.from_status is not null))
|
||||||
|
and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
order by c.id""",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
|
||||||
|
(rs, i) -> rs.getString("from_status") == null
|
||||||
|
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
|
||||||
|
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
|
||||||
|
rs.getString("to_status"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countWishlist}, same id range.
|
||||||
|
*/
|
||||||
|
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
|
||||||
|
return new DigestItems(jdbc.query("""
|
||||||
|
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
|
||||||
|
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
|
||||||
|
order by c.id""",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
|
||||||
|
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null when there is no row or the user did not send sections: nothing is filtered
|
* @return null when there is no row or the user did not send sections: nothing is filtered
|
||||||
*/
|
*/
|
||||||
@@ -115,10 +216,10 @@ public class FollowDigestRepository {
|
|||||||
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
|
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
|
||||||
* not deleted. A name is stored as it is now.
|
* not deleted. A name is stored as it is now.
|
||||||
*
|
*
|
||||||
* @return how many notes were written
|
* @return the ids of the notes written
|
||||||
*/
|
*/
|
||||||
public int insertDigestNotes(long userId, Digest digest) {
|
public List<Long> insertDigestNotes(long userId, Digest digest) {
|
||||||
return jdbc.update("""
|
return jdbc.query("""
|
||||||
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
|
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
|
||||||
status_changes, wishlist_adds)
|
status_changes, wishlist_adds)
|
||||||
select f.user_id, 'DIGEST', f.followed_user_id,
|
select f.user_id, 'DIGEST', f.followed_user_id,
|
||||||
@@ -128,9 +229,32 @@ public class FollowDigestRepository {
|
|||||||
where f.followed_user_id = :userId
|
where f.followed_user_id = :userId
|
||||||
and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
|
and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
|
||||||
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
|
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
|
||||||
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""",
|
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))
|
||||||
|
returning id""",
|
||||||
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
|
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
|
||||||
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()));
|
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()),
|
||||||
|
(rs, i) -> rs.getLong("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One copy of the lines per note: the note owns them and they go with it.
|
||||||
|
*/
|
||||||
|
public void insertDigestItems(List<Long> noteIds, DigestItems items) {
|
||||||
|
if (noteIds.isEmpty() || items.items().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<SqlParameterSource> rows = new ArrayList<>();
|
||||||
|
for (long noteId : noteIds) {
|
||||||
|
for (DigestItem item : items.items()) {
|
||||||
|
rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind())
|
||||||
|
.addValue("appid", item.appid()).addValue("gameName", item.gameName())
|
||||||
|
.addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus())
|
||||||
|
.addValue("toStatus", item.toStatus()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) "
|
||||||
|
+ "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)",
|
||||||
|
rows.toArray(new SqlParameterSource[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
|
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
|
|||||||
|
|
||||||
import core_service.modules.follow.domain.Cursor;
|
import core_service.modules.follow.domain.Cursor;
|
||||||
import core_service.modules.follow.domain.Digest;
|
import core_service.modules.follow.domain.Digest;
|
||||||
|
import core_service.modules.follow.domain.DigestItems;
|
||||||
import core_service.modules.follow.repo.FollowDigestRepository;
|
import core_service.modules.follow.repo.FollowDigestRepository;
|
||||||
import core_service.shared.event.LibraryActivityEvent;
|
import core_service.shared.event.LibraryActivityEvent;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -19,7 +20,8 @@ import java.util.Optional;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
|
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
|
||||||
* last batch. The cursor moves in every case, so what the profile hides is never reported later.
|
* last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets
|
||||||
|
* the lines behind its counts (status moves collapsed per game).
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -67,23 +69,31 @@ public class FollowDigestService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Cursor cursor = locked.get();
|
Cursor cursor = locked.get();
|
||||||
long upTo = digestRepository.maxStatusChangeId(userId);
|
long upTo = withSync ? digestRepository.maxStatusChangeId(userId)
|
||||||
|
: digestRepository.maxHandStatusChangeId(userId, cursor.statusChangeId());
|
||||||
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
|
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
|
||||||
Instant syncStartedAt = cursor.syncStartedAt();
|
Instant syncStartedAt = cursor.syncStartedAt();
|
||||||
long wishlistUpTo = cursor.wishlistChangeId();
|
long wishlistUpTo = cursor.wishlistChangeId();
|
||||||
|
Optional<Instant> current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty();
|
||||||
|
boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt));
|
||||||
|
DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync);
|
||||||
if (withSync) {
|
if (withSync) {
|
||||||
Optional<Instant> current = digestRepository.findSyncStartedAt(userId);
|
if (newSync) {
|
||||||
if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
|
|
||||||
digest = digest.plus(digestRepository.countSync(userId));
|
digest = digest.plus(digestRepository.countSync(userId));
|
||||||
|
items = items.plus(digestRepository.findSyncItems(userId));
|
||||||
syncStartedAt = current.get();
|
syncStartedAt = current.get();
|
||||||
}
|
}
|
||||||
wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
|
wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
|
||||||
digest = digest.plus(new Digest(0, 0, 0,
|
digest = digest.plus(new Digest(0, 0, 0,
|
||||||
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
|
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
|
||||||
|
items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo));
|
||||||
}
|
}
|
||||||
digest = digest.visibleOn(digestRepository.findPublicSections(userId));
|
List<String> sections = digestRepository.findPublicSections(userId);
|
||||||
|
digest = digest.visibleOn(sections);
|
||||||
|
items = items.visibleOn(sections).collapseStatus();
|
||||||
if (!digest.isEmpty()) {
|
if (!digest.isEmpty()) {
|
||||||
digestRepository.insertDigestNotes(userId, digest);
|
List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
|
||||||
|
digestRepository.insertDigestItems(noteIds, items);
|
||||||
}
|
}
|
||||||
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
|
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package core_service.modules.inbox.api;
|
package core_service.modules.inbox.api;
|
||||||
|
|
||||||
import core_service.modules.inbox.api.dto.InboxDto;
|
import core_service.modules.inbox.api.dto.InboxDto;
|
||||||
|
import core_service.modules.inbox.api.dto.NoteDetailsDto;
|
||||||
import core_service.modules.inbox.domain.Inbox;
|
import core_service.modules.inbox.domain.Inbox;
|
||||||
import core_service.modules.inbox.service.InboxService;
|
import core_service.modules.inbox.service.InboxService;
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.shared.security.UserPrincipal;
|
import core_service.shared.security.UserPrincipal;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -24,10 +28,21 @@ public class InboxController {
|
|||||||
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
|
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
|
||||||
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
|
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
|
||||||
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(),
|
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(),
|
||||||
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read()))
|
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
|
||||||
.toList(), inbox.unread()));
|
.toList(), inbox.unread()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/details")
|
||||||
|
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
|
||||||
|
return inboxService.details(principal.getIdAsLong(), id)
|
||||||
|
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
|
||||||
|
.map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(),
|
||||||
|
detail.itemName(), detail.fromStatus(), detail.toStatus()))
|
||||||
|
.toList())))
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new ErrorResponse("NOTE_NOT_FOUND", "No such note")));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/read")
|
@PutMapping("/read")
|
||||||
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
inboxService.markAllRead(principal.getIdAsLong());
|
inboxService.markAllRead(principal.getIdAsLong());
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ public record InboxDto(List<Note> results, int unread) {
|
|||||||
/**
|
/**
|
||||||
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
|
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
|
||||||
* @param displayName the name of the followed user when the note was written; never a user id
|
* @param displayName the name of the followed user when the note was written; never a user id
|
||||||
|
* @param details the note can be opened: it has detail rows and the subject is still followed and public
|
||||||
*/
|
*/
|
||||||
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
|
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
|
||||||
int wishlistAdds, Instant createdAt, boolean read) {
|
int wishlistAdds, Instant createdAt, boolean read, boolean details) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core_service.modules.inbox.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record NoteDetailsDto(List<Detail> results) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
|
||||||
|
* @param itemName ACHIEVEMENT only: the achievement or trophy name
|
||||||
|
* @param fromStatus STATUS only
|
||||||
|
* @param toStatus STATUS only
|
||||||
|
*/
|
||||||
|
public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import java.time.Instant;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param subjectName the name of the followed user when the note was written
|
* @param subjectName the name of the followed user when the note was written
|
||||||
|
* @param details the note can be opened: it has detail rows and the subject is still followed and public
|
||||||
*/
|
*/
|
||||||
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
|
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
|
||||||
int statusChanges, int wishlistAdds, Instant createdAt, boolean read) {
|
int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line behind a digest note. Names are snapshots from when the note was written.
|
||||||
|
*/
|
||||||
|
public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public enum NoteDetailKind {
|
||||||
|
NEW_GAME,
|
||||||
|
ACHIEVEMENT,
|
||||||
|
STATUS,
|
||||||
|
WISHLIST;
|
||||||
|
|
||||||
|
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of follow.domain.Digest.visibleOn, modules may not import each other.
|
||||||
|
*
|
||||||
|
* @param sections the profile parts the user shows, null = all
|
||||||
|
*/
|
||||||
|
public boolean visibleOn(Collection<String> sections) {
|
||||||
|
if (sections == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return switch (this) {
|
||||||
|
case NEW_GAME, ACHIEVEMENT -> sections.contains("SUMMARY");
|
||||||
|
case STATUS -> STATUS_SECTIONS.stream().anyMatch(sections::contains);
|
||||||
|
case WISHLIST -> true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package core_service.modules.inbox.repo;
|
package core_service.modules.inbox.repo;
|
||||||
|
|
||||||
import core_service.modules.inbox.domain.InboxNote;
|
import core_service.modules.inbox.domain.InboxNote;
|
||||||
|
import core_service.modules.inbox.domain.NoteDetail;
|
||||||
|
import core_service.modules.inbox.domain.NoteDetailKind;
|
||||||
import core_service.modules.inbox.domain.NoteKind;
|
import core_service.modules.inbox.domain.NoteKind;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Array;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -19,14 +24,63 @@ public class InboxRepository {
|
|||||||
|
|
||||||
public List<InboxNote> findNewest(long userId, int limit) {
|
public List<InboxNote> findNewest(long userId, int limit) {
|
||||||
return jdbc.query("""
|
return jdbc.query("""
|
||||||
select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at
|
select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
|
||||||
from inbox_note where user_id = :userId
|
n.created_at, n.read_at,
|
||||||
order by created_at desc, id desc
|
exists (select 1 from inbox_note_detail d where d.note_id = n.id)
|
||||||
|
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
|
||||||
|
and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details
|
||||||
|
from inbox_note n where n.user_id = :userId
|
||||||
|
order by n.created_at desc, n.id desc
|
||||||
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
|
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
|
||||||
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
|
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
|
||||||
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
|
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
|
||||||
rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
|
rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
|
||||||
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null));
|
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
|
||||||
|
rs.getBoolean("details")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ownership and the follow re-check in one statement.
|
||||||
|
*
|
||||||
|
* @return the followed user of a DIGEST note of this user, empty when the note is not theirs, they no longer
|
||||||
|
* follow the subject or the subject is no longer public
|
||||||
|
*/
|
||||||
|
public Optional<Long> findFollowedSubject(long userId, long noteId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select n.subject_user_id from inbox_note n
|
||||||
|
where n.id = :noteId and n.user_id = :userId and n.kind = 'DIGEST'
|
||||||
|
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
|
||||||
|
and exists (select 1 from public_user p where p.user_id = n.subject_user_id)""",
|
||||||
|
new MapSqlParameterSource("userId", userId).addValue("noteId", noteId),
|
||||||
|
(rs, i) -> rs.getLong("subject_user_id")).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null when there is no row or the user did not send sections: nothing is filtered
|
||||||
|
*/
|
||||||
|
public List<String> findPublicSections(long subjectUserId) {
|
||||||
|
return jdbc.query("select sections from public_user where user_id = :userId",
|
||||||
|
new MapSqlParameterSource("userId", subjectUserId), rs -> {
|
||||||
|
if (!rs.next()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Array sections = rs.getArray("sections");
|
||||||
|
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without the games the subject hides now.
|
||||||
|
*/
|
||||||
|
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
|
||||||
|
where d.note_id = :noteId
|
||||||
|
and not exists (select 1 from user_game ug
|
||||||
|
where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null)
|
||||||
|
order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId),
|
||||||
|
(rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
|
||||||
|
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
|
||||||
}
|
}
|
||||||
|
|
||||||
public int countUnread(long userId) {
|
public int countUnread(long userId) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.inbox.service;
|
package core_service.modules.inbox.service;
|
||||||
|
|
||||||
import core_service.modules.inbox.domain.Inbox;
|
import core_service.modules.inbox.domain.Inbox;
|
||||||
|
import core_service.modules.inbox.domain.NoteDetail;
|
||||||
import core_service.modules.inbox.repo.InboxRepository;
|
import core_service.modules.inbox.repo.InboxRepository;
|
||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
@@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -32,6 +35,22 @@ public class InboxService {
|
|||||||
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
|
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checked again on every read: the caller must own the note, still follow the subject, the subject must be
|
||||||
|
* public and show the part.
|
||||||
|
*
|
||||||
|
* @return empty when the note cannot be opened
|
||||||
|
*/
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<List<NoteDetail>> details(long userId, long noteId) {
|
||||||
|
return inboxRepository.findFollowedSubject(userId, noteId).map(subject -> {
|
||||||
|
List<String> sections = inboxRepository.findPublicSections(subject);
|
||||||
|
return inboxRepository.findDetails(noteId, subject).stream()
|
||||||
|
.filter(detail -> detail.kind().visibleOn(sections)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@Transactional
|
@Transactional
|
||||||
public void markAllRead(long userId) {
|
public void markAllRead(long userId) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- What a DIGEST note counted (#27 follow-up). Names are snapshots; the row goes with its note.
|
||||||
|
CREATE TABLE inbox_note_detail (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
note_id BIGINT NOT NULL REFERENCES inbox_note (id) ON DELETE CASCADE,
|
||||||
|
kind VARCHAR(16) NOT NULL CHECK (kind IN ('NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST')),
|
||||||
|
appid BIGINT NOT NULL,
|
||||||
|
game_name VARCHAR(512), -- steam_game.name / wishlist_game.name at write time
|
||||||
|
item_name VARCHAR(512), -- ACHIEVEMENT: achievement or trophy name
|
||||||
|
from_status VARCHAR(16), -- STATUS only
|
||||||
|
to_status VARCHAR(16)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_inbox_note_detail_note ON inbox_note_detail (note_id);
|
||||||
@@ -68,14 +68,19 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyMapping_PointsToAnExistingAnswer() throws IOException {
|
void everyMapping_PointsToAnExistingAnswer() throws IOException {
|
||||||
List<Path> mappings;
|
List<Path> mappings = sortedFiles(MAPPINGS);
|
||||||
try (Stream<Path> listed = Files.list(MAPPINGS)) {
|
|
||||||
mappings = listed.sorted().toList();
|
|
||||||
}
|
|
||||||
assertThat(mappings).isNotEmpty();
|
assertThat(mappings).isNotEmpty();
|
||||||
|
|
||||||
for (Path mapping : mappings) {
|
for (Path mapping : mappings) {
|
||||||
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
|
JsonNode root = jsonMapper.readTree(mapping.toFile());
|
||||||
|
JsonNode response = root.path("response");
|
||||||
|
// A copied achievement mapping must ask for the appid in its own name.
|
||||||
|
String name = mapping.getFileName().toString();
|
||||||
|
if (name.matches("steam-(schema|player-achievements|global-percentages)-\\d+\\.json")) {
|
||||||
|
JsonNode query = root.path("request").path("queryParameters");
|
||||||
|
String asked = query.path(query.has("appid") ? "appid" : "gameid").path("equalTo").asString();
|
||||||
|
assertThat(name).as(mapping.toString()).isEqualTo(name.replaceAll("\\d+", asked));
|
||||||
|
}
|
||||||
// The PlayStation authorize answer is a redirect with no body.
|
// The PlayStation authorize answer is a redirect with no body.
|
||||||
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
|
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
|
||||||
.as(mapping.toString()).isTrue();
|
.as(mapping.toString()).isTrue();
|
||||||
@@ -105,10 +110,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
|
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
|
||||||
List<Path> files;
|
List<Path> files = sortedFiles(FILES);
|
||||||
try (Stream<Path> listed = Files.list(FILES)) {
|
|
||||||
files = listed.sorted().toList();
|
|
||||||
}
|
|
||||||
assertThat(files).isNotEmpty();
|
assertThat(files).isNotEmpty();
|
||||||
|
|
||||||
for (Path file : files) {
|
for (Path file : files) {
|
||||||
@@ -121,4 +123,30 @@ class LocalMocksParseTest extends AbstractUnitTest {
|
|||||||
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
|
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A copied stub with a typo in its appid is otherwise only a silent 400 in the sync log.
|
||||||
|
@Test
|
||||||
|
void everyAchievementStub_BelongsToAnOwnedGame() throws IOException {
|
||||||
|
List<String> owned = jsonMapper.readValue(FILES.resolve("steam-owned-games.json").toFile(), SteamOwnedGamesResponse.class)
|
||||||
|
.response().games().stream()
|
||||||
|
.map(game -> game.appid().toString())
|
||||||
|
.toList();
|
||||||
|
List<Path> schemas = sortedFiles(FILES).stream()
|
||||||
|
.filter(file -> file.getFileName().toString().matches("steam-schema-\\d+\\.json"))
|
||||||
|
.toList();
|
||||||
|
assertThat(schemas).isNotEmpty();
|
||||||
|
|
||||||
|
for (Path schema : schemas) {
|
||||||
|
String appid = schema.getFileName().toString().replaceAll("\\D", "");
|
||||||
|
assertThat(owned).as(schema.toString()).contains(appid);
|
||||||
|
assertThat(FILES.resolve("steam-player-achievements-" + appid + ".json")).exists();
|
||||||
|
assertThat(FILES.resolve("steam-global-percentages-" + appid + ".json")).exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Path> sortedFiles(Path dir) throws IOException {
|
||||||
|
try (Stream<Path> listed = Files.list(dir)) {
|
||||||
|
return listed.sorted().toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DigestItemsTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static DigestItems of(DigestItem... items) {
|
||||||
|
return new DigestItems(List.of(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final DigestItems ALL = of(DigestItem.newGame(1, "A"), DigestItem.achievement(1, "A", "Win"),
|
||||||
|
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.wishlist(3, "C"));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collapseStatus_FirstFromToLastTo() {
|
||||||
|
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
|
||||||
|
|
||||||
|
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.status(1, "A", "BACKLOG", "FINISHED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collapseStatus_DropsAGameThatEndsWhereItStarted() {
|
||||||
|
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "BACKLOG"));
|
||||||
|
|
||||||
|
assertThat(items.collapseStatus().items()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collapseStatus_KeepsGamesApartAndOtherKindsAndOrder() {
|
||||||
|
DigestItems items = of(DigestItem.newGame(9, "N"), DigestItem.status(1, "A", "BACKLOG", "PLAYING"),
|
||||||
|
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
|
||||||
|
|
||||||
|
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.newGame(9, "N"),
|
||||||
|
DigestItem.status(1, "A", "BACKLOG", "FINISHED"), DigestItem.status(2, "B", "BACKLOG", "PLAYING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_NullKeepsAll() {
|
||||||
|
assertThat(ALL.visibleOn(null)).isEqualTo(ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_StatusSectionDropsNewGamesAndAchievementsAndKeepsWishlist() {
|
||||||
|
assertThat(ALL.visibleOn(Set.of("STATUS")).items()).extracting(DigestItem::kind)
|
||||||
|
.containsExactly(DigestItem.STATUS, DigestItem.WISHLIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_SummaryKeepsNewGamesAndAchievementsButNotStatus() {
|
||||||
|
assertThat(ALL.visibleOn(Set.of("SUMMARY")).items()).extracting(DigestItem::kind)
|
||||||
|
.containsExactly(DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT, DigestItem.WISHLIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plus_KeepsReceiverFirst() {
|
||||||
|
assertThat(of(DigestItem.wishlist(1, "A")).plus(of(DigestItem.wishlist(2, "B"))).items())
|
||||||
|
.extracting(DigestItem::appid).containsExactly(1L, 2L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package core_service.modules.follow.repo;
|
package core_service.modules.follow.repo;
|
||||||
|
|
||||||
import core_service.modules.follow.domain.Digest;
|
import core_service.modules.follow.domain.Digest;
|
||||||
|
import core_service.modules.follow.domain.DigestItem;
|
||||||
|
import core_service.modules.follow.domain.DigestItems;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -8,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@Import(FollowDigestRepository.class)
|
@Import(FollowDigestRepository.class)
|
||||||
@@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
|
|||||||
void seed() {
|
void seed() {
|
||||||
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
|
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
|
||||||
for (long appid : new long[]{LISTED, REMOVED}) {
|
for (long appid : new long[]{LISTED, REMOVED}) {
|
||||||
jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid);
|
jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Game " + appid);
|
||||||
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
|
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
|
||||||
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
|
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
|
||||||
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
|
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
|
||||||
@@ -49,4 +53,81 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
assertThat(digest.newGames()).isEqualTo(1);
|
assertThat(digest.newGames()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void unlock(long appid, String apiName, String displayName, int daysAgo) {
|
||||||
|
jdbc.update("insert into game_achievement_schema (appid, api_name, display_name) values (?, ?, ?)", appid, apiName, displayName);
|
||||||
|
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
|
||||||
|
+ "values (?, ?, ?, true, now() - make_interval(days => ?))", USER, appid, apiName, daysAgo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSyncItems_SkipsRemovedGamesAndNamesTheGame() {
|
||||||
|
DigestItems items = repository.findSyncItems(USER);
|
||||||
|
|
||||||
|
assertThat(items.items()).contains(DigestItem.newGame(LISTED, "Game 10"));
|
||||||
|
assertThat(items.items()).noneMatch(item -> item.appid() == REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSyncItems_TakesTheAmountNewestUnlocks() {
|
||||||
|
unlock(LISTED, "a", "Oldest", 40);
|
||||||
|
unlock(LISTED, "b", "Third", 30);
|
||||||
|
unlock(LISTED, "c", "Second", 20);
|
||||||
|
unlock(LISTED, "d", "Newest", 10);
|
||||||
|
unlock(REMOVED, "e", "Removed one", 1);
|
||||||
|
|
||||||
|
List<DigestItem> achievements = repository.findSyncItems(USER).items().stream()
|
||||||
|
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).toList();
|
||||||
|
|
||||||
|
assertThat(achievements).containsExactly(DigestItem.achievement(LISTED, "Game 10", "Newest"),
|
||||||
|
DigestItem.achievement(LISTED, "Game 10", "Second"), DigestItem.achievement(LISTED, "Game 10", "Third"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSyncItems_PsnTrophiesCompeteWithSteamUnlocksOfTheSameGame() {
|
||||||
|
unlock(LISTED, "a", "Old steam", 40);
|
||||||
|
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
|
||||||
|
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1', 'trophy', 'PS4', 'T', 1, 0, 0, 0)");
|
||||||
|
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 1, 'BRONZE', 'Trophy one')");
|
||||||
|
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 2, 'BRONZE', null)");
|
||||||
|
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
|
||||||
|
+ "values (?, 'NPWR1', ?, 2, false)", USER, LISTED);
|
||||||
|
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 1, now() - interval '1 day')", USER);
|
||||||
|
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 2, now() - interval '2 days')", USER);
|
||||||
|
|
||||||
|
List<String> names = repository.findSyncItems(USER).items().stream()
|
||||||
|
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).map(DigestItem::itemName).toList();
|
||||||
|
|
||||||
|
assertThat(names).containsExactly("Trophy one", "Trophy 2", "Old steam");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findWishlistItems_UsesTheWishlistName() {
|
||||||
|
jdbc.update("insert into wishlist_game (user_id, appid, name) values (?, 777, 'Wished')", USER);
|
||||||
|
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'WISHLIST', 777)", USER);
|
||||||
|
|
||||||
|
assertThat(repository.findWishlistItems(USER, 0, Long.MAX_VALUE).items())
|
||||||
|
.containsExactly(DigestItem.wishlist(777, "Wished"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findHandItems_NoOldStatusIsANewGame() {
|
||||||
|
assertThat(repository.findHandItems(USER, 0, Long.MAX_VALUE, false).items())
|
||||||
|
.containsExactly(DigestItem.newGame(LISTED, "Game 10"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertDigestNotesAndItems_ReturnIdsAndWriteRowsPerNote() {
|
||||||
|
jdbc.update("insert into public_user (user_id) values (?)", USER);
|
||||||
|
jdbc.update("insert into follow (user_id, followed_user_id) values (2, ?), (3, ?)", USER, USER);
|
||||||
|
|
||||||
|
List<Long> ids = repository.insertDigestNotes(USER, new Digest(1, 0, 0, 0));
|
||||||
|
repository.insertDigestItems(ids, new DigestItems(List.of(DigestItem.newGame(LISTED, "Game 10"),
|
||||||
|
DigestItem.wishlist(5, "W"))));
|
||||||
|
|
||||||
|
assertThat(ids).hasSize(2);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isEqualTo(4);
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail where note_id = ?", Integer.class, ids.get(0)))
|
||||||
|
.isEqualTo(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,13 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
|
|||||||
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount);
|
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void moved(long appid) {
|
||||||
|
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
|
||||||
|
FOLLOWED, appid);
|
||||||
|
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'BACKLOG', 'PLAYING', false)",
|
||||||
|
FOLLOWED, appid);
|
||||||
|
}
|
||||||
|
|
||||||
private void added(int count) {
|
private void added(int count) {
|
||||||
games(count);
|
games(count);
|
||||||
sync();
|
sync();
|
||||||
@@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
|
|||||||
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed");
|
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> details(long reader) {
|
||||||
|
return jdbc.queryForList("select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d "
|
||||||
|
+ "join inbox_note n on n.id = d.note_id where n.user_id = ? order by d.id", reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncBatch_WritesTheLinesBehindTheCountsForEachFollower() {
|
||||||
|
added(2);
|
||||||
|
moved(FIRST_APPID + 1);
|
||||||
|
change("WISHLIST", 777_001L, null);
|
||||||
|
|
||||||
|
event();
|
||||||
|
|
||||||
|
for (long reader : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) {
|
||||||
|
assertThat(details(reader)).extracting(row -> row.get("kind"))
|
||||||
|
.containsExactlyInAnyOrder("NEW_GAME", "NEW_GAME", "STATUS", "WISHLIST");
|
||||||
|
assertThat(details(reader)).anySatisfy(row -> assertThat(row).containsEntry("game_name", "Game 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handChangesThereAndBack_WriteNoStatusLineButTheCountStays() {
|
||||||
|
handGame(920_001L, 30);
|
||||||
|
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
jdbc.update("update user_game_status_change set changed_at = now() - interval '30 minutes' where user_id = ?", FOLLOWED);
|
||||||
|
|
||||||
|
service.digestHandChanges();
|
||||||
|
|
||||||
|
assertThat(notes().get(0)).containsEntry("status_changes", 2);
|
||||||
|
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).doesNotContain("STATUS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handMoveBeforeSyncMoveGivesOneLineFromFirstToLast() {
|
||||||
|
handGame(920_001L, 30);
|
||||||
|
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
sync();
|
||||||
|
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'PLAYING', 'FINISHED')",
|
||||||
|
FOLLOWED, 920_001L);
|
||||||
|
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'PLAYING', 'FINISHED', false)",
|
||||||
|
FOLLOWED, 920_001L);
|
||||||
|
|
||||||
|
event();
|
||||||
|
|
||||||
|
assertThat(notes().get(0)).containsEntry("status_changes", 2);
|
||||||
|
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
|
||||||
|
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
|
||||||
|
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncMoveBeforeHandMoveGivesOneLineFromFirstToLast() {
|
||||||
|
handGame(920_001L, 30);
|
||||||
|
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
sync();
|
||||||
|
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = false where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
|
||||||
|
FOLLOWED, 920_001L);
|
||||||
|
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?", FOLLOWED, 920_001L);
|
||||||
|
|
||||||
|
event();
|
||||||
|
|
||||||
|
assertThat(notes().get(0)).containsEntry("status_changes", 2);
|
||||||
|
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
|
||||||
|
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
|
||||||
|
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusSectionOnly_WritesNoNewGameOrAchievementLines() {
|
||||||
|
added(2);
|
||||||
|
change("ACHIEVEMENTS", FIRST_APPID + 1, 3);
|
||||||
|
moved(FIRST_APPID + 1);
|
||||||
|
sections("STATUS");
|
||||||
|
|
||||||
|
event();
|
||||||
|
|
||||||
|
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).containsExactly("STATUS");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noActivity_WritesNoNoteButMovesTheCursor() {
|
void noActivity_WritesNoNoteButMovesTheCursor() {
|
||||||
sync();
|
sync();
|
||||||
@@ -351,4 +439,20 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
|
|||||||
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
|
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
|
||||||
Integer.class)).isEqualTo(2);
|
Integer.class)).isEqualTo(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handJobBeforeSync_LeavesTheSyncStatusRowsForTheSyncDigest() {
|
||||||
|
handGame(920_001L, 30);
|
||||||
|
added(1);
|
||||||
|
moved(FIRST_APPID + 1);
|
||||||
|
|
||||||
|
service.digestHandChanges();
|
||||||
|
event();
|
||||||
|
|
||||||
|
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind"), row -> row.get("game_name"))
|
||||||
|
.containsExactlyInAnyOrder(org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Hand"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Game 1"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple("STATUS", "Game 1"));
|
||||||
|
assertThat(notes()).extracting(note -> note.get("status_changes")).containsOnly(0, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,23 @@ class InboxControllerTest extends AbstractIntegrationTest {
|
|||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from inbox_note");
|
jdbc.update("delete from inbox_note");
|
||||||
|
jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER);
|
||||||
|
jdbc.update("delete from public_user where user_id = 7");
|
||||||
|
jdbc.update("delete from user_game where user_id = 7");
|
||||||
|
jdbc.update("delete from steam_game where appid = 501");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note of READER with lines, the subject (7) public and followed. */
|
||||||
|
private long noteWithDetails() {
|
||||||
|
note(READER, "Subject", 1, 2);
|
||||||
|
long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class);
|
||||||
|
jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER);
|
||||||
|
jdbc.update("insert into public_user (user_id) values (7)");
|
||||||
|
jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values "
|
||||||
|
+ "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), "
|
||||||
|
+ "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), "
|
||||||
|
+ "(?, 'STATUS', 502, 'Beta', null, 'BACKLOG', 'PLAYING')", id, id, id);
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
|
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
|
||||||
@@ -95,4 +112,62 @@ class InboxControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.results[0].read").value(true));
|
.andExpect(jsonPath("$.results[0].read").value(true));
|
||||||
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1));
|
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_OfAnOwnNoteListsTheLinesWithoutIdentifiers() throws Exception {
|
||||||
|
long id = noteWithDetails();
|
||||||
|
|
||||||
|
String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.results.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.results[0].kind").value("NEW_GAME"))
|
||||||
|
.andExpect(jsonPath("$.results[1].itemName").value("Win"))
|
||||||
|
.andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG"))
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
assertThat(body).doesNotContain("appid", "subject", "userId");
|
||||||
|
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_OfAnotherReadersNoteIs404() throws Exception {
|
||||||
|
long id = noteWithDetails();
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), OTHER)).andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.error").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_AfterUnfollowIs404AndTheListShowsNoDetails() throws Exception {
|
||||||
|
long id = noteWithDetails();
|
||||||
|
jdbc.update("delete from follow where user_id = ?", READER);
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isNotFound());
|
||||||
|
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_OnlyKindsOfTheSectionsThatAreStillPublic() throws Exception {
|
||||||
|
long id = noteWithDetails();
|
||||||
|
jdbc.update("update public_user set sections = '{STATUS}' where user_id = 7");
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.results.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.results[0].kind").value("STATUS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_LeaveOutAGameTheSubjectHidesNow() throws Exception {
|
||||||
|
long id = noteWithDetails();
|
||||||
|
jdbc.update("insert into steam_game (appid, name) values (501, 'Alpha') on conflict do nothing");
|
||||||
|
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (7, 501, 'BACKLOG', now())");
|
||||||
|
|
||||||
|
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.results.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.results[0].gameName").value("Beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void details_WithoutLoginIs401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/inbox/1/details")).andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class NoteDetailKindTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_NullSectionsShowsEverything() {
|
||||||
|
for (NoteDetailKind kind : NoteDetailKind.values()) {
|
||||||
|
assertThat(kind.visibleOn(null)).as(kind.name()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_SummaryShowsGamesAndAchievementsOnly() {
|
||||||
|
Set<String> sections = Set.of("SUMMARY");
|
||||||
|
|
||||||
|
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).isTrue();
|
||||||
|
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).isTrue();
|
||||||
|
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).isFalse();
|
||||||
|
assertThat(NoteDetailKind.WISHLIST.visibleOn(sections)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_EachStatusSectionShowsStatusButNotGames() {
|
||||||
|
for (String section : new String[]{"STATUS", "PLAYING", "FINISHED"}) {
|
||||||
|
Set<String> sections = Set.of(section);
|
||||||
|
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).as(section).isTrue();
|
||||||
|
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).as(section).isFalse();
|
||||||
|
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).as(section).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void visibleOn_OnlyStatsLeavesTheWishlist() {
|
||||||
|
assertThat(NoteDetailKind.WISHLIST.visibleOn(Set.of("STATS"))).isTrue();
|
||||||
|
assertThat(NoteDetailKind.STATUS.visibleOn(Set.of("STATS"))).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,4 +43,32 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(deleted).isEqualTo(2);
|
assertThat(deleted).isEqualTo(2);
|
||||||
assertThat(ids()).containsExactly(3L, 4L);
|
assertThat(ids()).containsExactly(3L, 4L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBefore_AlsoRemovesTheDetailRows() {
|
||||||
|
jdbc.update("delete from inbox_note");
|
||||||
|
note(1, 91, true);
|
||||||
|
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
|
||||||
|
|
||||||
|
repository.deleteBefore(Instant.now().minus(Duration.ofDays(90)));
|
||||||
|
|
||||||
|
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findNewest_DetailsNeedRowsAndAFollowAndAPublicSubject() {
|
||||||
|
jdbc.update("delete from inbox_note");
|
||||||
|
note(1, 1, false);
|
||||||
|
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
|
||||||
|
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
|
||||||
|
|
||||||
|
jdbc.update("insert into follow (user_id, followed_user_id) values (1, 2)");
|
||||||
|
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
|
||||||
|
|
||||||
|
jdbc.update("insert into public_user (user_id) values (2)");
|
||||||
|
assertThat(repository.findNewest(1, 10).get(0).details()).isTrue();
|
||||||
|
|
||||||
|
jdbc.update("delete from inbox_note_detail");
|
||||||
|
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user