diff --git a/src/main/java/com/runediary/sync/HiscoresFetcher.java b/src/main/java/com/runediary/sync/HiscoresFetcher.java new file mode 100644 index 0000000..c013bd1 --- /dev/null +++ b/src/main/java/com/runediary/sync/HiscoresFetcher.java @@ -0,0 +1,98 @@ +package com.runediary.sync; + +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Fetches the raw OSRS hi-scores CSV for the current player and forwards + * it to the website verbatim — the server does the parsing so the plugin + * never needs an update when Jagex adds a new boss. (All positional + * activity/skill constants live in types/constants.ts on the server.) + * + * Distributing the HTTP call to plugin clients keeps server-side OSRS API + * usage bounded — we never pay for these requests centrally. + */ +@Slf4j +public class HiscoresFetcher +{ + // Each account type has its own hi-score endpoint. Picking the wrong + // one returns the wrong dataset (e.g. an ironman's main-world stats). + private static final String BASE_REGULAR = + "https://secure.runescape.com/m=hiscore_oldschool/index_lite.ws"; + private static final String BASE_IRONMAN = + "https://secure.runescape.com/m=hiscore_oldschool_ironman/index_lite.ws"; + private static final String BASE_HARDCORE = + "https://secure.runescape.com/m=hiscore_oldschool_hardcore_ironman/index_lite.ws"; + private static final String BASE_ULTIMATE = + "https://secure.runescape.com/m=hiscore_oldschool_ultimate/index_lite.ws"; + + private final OkHttpClient httpClient; + + public HiscoresFetcher(OkHttpClient httpClient) + { + this.httpClient = httpClient; + } + + /** + * Synchronously fetches the hi-scores CSV. Call from a background + * thread — never from the RuneLite client thread. + * + * @return CSV body, or null on any failure (404 / timeout / non-2xx) + */ + public String fetchRaw(String playerName, String accountType) + { + if (playerName == null || playerName.isEmpty()) + { + return null; + } + HttpUrl base = HttpUrl.parse(endpointFor(accountType)); + if (base == null) + { + return null; + } + HttpUrl url = base.newBuilder() + .addQueryParameter("player", playerName) + .build(); + Request request = new Request.Builder().url(url).get().build(); + try (Response response = httpClient.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + // 404 just means the player isn't on hi-scores yet — not an error. + log.debug("Hi-scores fetch returned {} for {}", response.code(), playerName); + return null; + } + ResponseBody body = response.body(); + return body == null ? null : body.string(); + } + catch (IOException e) + { + log.debug("Hi-scores fetch failed for {}: {}", playerName, e.getMessage()); + return null; + } + } + + private static String endpointFor(String accountType) + { + if (accountType == null) + { + return BASE_REGULAR; + } + switch (accountType) + { + case "IRONMAN": + return BASE_IRONMAN; + case "HARDCORE_IRONMAN": + return BASE_HARDCORE; + case "ULTIMATE_IRONMAN": + return BASE_ULTIMATE; + default: + return BASE_REGULAR; + } + } +} diff --git a/src/main/java/com/runediary/sync/ProfileSyncService.java b/src/main/java/com/runediary/sync/ProfileSyncService.java index 9a9161a..f0e0a90 100644 --- a/src/main/java/com/runediary/sync/ProfileSyncService.java +++ b/src/main/java/com/runediary/sync/ProfileSyncService.java @@ -49,6 +49,7 @@ public class ProfileSyncService private final ClientThread clientThread; private final PlayerContext playerContext; private final net.runelite.client.game.ItemManager itemManager; + private final HiscoresFetcher hiscoresFetcher; private static final int COLLECTION_LOG_ITEM_SCRIPT = 4100; private static final int COLLECTION_LOG_SETUP_SCRIPT = 7797; @@ -93,6 +94,7 @@ public ProfileSyncService(Gson gson, Client client, RuneDiaryConfig config, OkHt this.clientThread = clientThread; this.playerContext = playerContext; this.itemManager = itemManager; + this.hiscoresFetcher = new HiscoresFetcher(httpClient); } public void onLoginReady() @@ -461,11 +463,20 @@ public void triggerSync() public void syncOnLogout() { - // On logout we're still on the client thread so we can read game state + // On logout we're still on the client thread so we can read game state. + // The hi-scores fetch is blocking IO so it has to wait for the + // executor thread; we attach the raw CSV body to the payload there + // before sendSync(). The server parses + merges — see + // /api/runescape-events/profile-sync and lib/hiscores/parse.ts. try { Map payload = buildProfilePayload(); - executor.submit(() -> sendSync(payload)); + String name = playerContext.getPlayerName(); + String accountType = playerContext.getAccountType(); + executor.submit(() -> { + attachHiscoresRaw(payload, name, accountType); + sendSync(payload); + }); } catch (Exception e) { @@ -473,6 +484,26 @@ public void syncOnLogout() } } + /** + * Fetches the OSRS hi-scores CSV for the player and tucks it into the + * payload as `hiscoresRaw`. Server-side parsing handles slug→display + * translation and merging into existing boss data, so the plugin never + * needs an update when Jagex adds a new boss to hi-scores. + * + * Safe to fail silently: on 404 / timeout / network error we just send + * the rest of the payload without the hi-scores blob — the chat tracker + * data still gets through. + */ + void attachHiscoresRaw(Map payload, String playerName, String accountType) + { + String raw = hiscoresFetcher.fetchRaw(playerName, accountType); + if (raw == null || raw.isEmpty()) + { + return; + } + payload.put("hiscoresRaw", raw); + } + private void collectAndSync() { if (!playerContext.isValid()) diff --git a/src/test/java/com/runediary/sync/HiscoresFetcherTest.java b/src/test/java/com/runediary/sync/HiscoresFetcherTest.java new file mode 100644 index 0000000..1c90c49 --- /dev/null +++ b/src/test/java/com/runediary/sync/HiscoresFetcherTest.java @@ -0,0 +1,120 @@ +package com.runediary.sync; + +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class HiscoresFetcherTest +{ + private OkHttpClient httpClient; + private HiscoresFetcher fetcher; + + @Before + public void setUp() + { + httpClient = mock(OkHttpClient.class); + fetcher = new HiscoresFetcher(httpClient); + } + + private void stubResponse(int code, String body) throws Exception + { + Call call = mock(Call.class); + Response response = new Response.Builder() + .request(new Request.Builder().url("https://secure.runescape.com/").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(code == 200 ? "OK" : "Not Found") + .body(ResponseBody.create(MediaType.parse("text/plain"), body)) + .build(); + when(call.execute()).thenReturn(response); + when(httpClient.newCall(any(Request.class))).thenReturn(call); + } + + @Test + public void fetchRaw_returnsBodyOn200() throws Exception + { + stubResponse(200, "1,2277,200000000\n"); + String body = fetcher.fetchRaw("hosama0", "NORMAL"); + assertEquals("1,2277,200000000\n", body); + } + + @Test + public void fetchRaw_returnsNullOn404() throws Exception + { + stubResponse(404, ""); + assertNull(fetcher.fetchRaw("nonexistent", "NORMAL")); + } + + @Test + public void fetchRaw_returnsNullForBlankName() throws Exception + { + assertNull(fetcher.fetchRaw("", "NORMAL")); + assertNull(fetcher.fetchRaw(null, "NORMAL")); + } + + @Test + public void fetchRaw_picksIronmanEndpointForIronmanAccount() throws Exception + { + stubResponse(200, "ok"); + fetcher.fetchRaw("Iron Hosama", "IRONMAN"); + + ArgumentCaptor req = ArgumentCaptor.forClass(Request.class); + verify(httpClient).newCall(req.capture()); + String url = req.getValue().url().toString(); + assertTrue("Should hit ironman endpoint", url.contains("hiscore_oldschool_ironman")); + assertTrue("Should include player name", url.contains("player=Iron%20Hosama")); + } + + @Test + public void fetchRaw_picksHardcoreEndpointForHCIM() throws Exception + { + stubResponse(200, "ok"); + fetcher.fetchRaw("HCIM Hosama", "HARDCORE_IRONMAN"); + + ArgumentCaptor req = ArgumentCaptor.forClass(Request.class); + verify(httpClient).newCall(req.capture()); + assertTrue(req.getValue().url().toString().contains("hiscore_oldschool_hardcore_ironman")); + } + + @Test + public void fetchRaw_picksUltimateEndpointForUIM() throws Exception + { + stubResponse(200, "ok"); + fetcher.fetchRaw("UIM Hosama", "ULTIMATE_IRONMAN"); + + ArgumentCaptor req = ArgumentCaptor.forClass(Request.class); + verify(httpClient).newCall(req.capture()); + assertTrue(req.getValue().url().toString().contains("hiscore_oldschool_ultimate")); + } + + @Test + public void fetchRaw_defaultsToRegularEndpointForUnknownAccountType() throws Exception + { + stubResponse(200, "ok"); + fetcher.fetchRaw("Hosama", null); + + ArgumentCaptor req = ArgumentCaptor.forClass(Request.class); + verify(httpClient).newCall(req.capture()); + String url = req.getValue().url().toString(); + assertTrue(url.contains("hiscore_oldschool/")); + // Should NOT contain any of the variant subpaths. + assertTrue(!url.contains("hiscore_oldschool_ironman")); + assertTrue(!url.contains("hiscore_oldschool_ultimate")); + } +} diff --git a/src/test/java/com/runediary/sync/ProfileSyncServiceHiscoresTest.java b/src/test/java/com/runediary/sync/ProfileSyncServiceHiscoresTest.java new file mode 100644 index 0000000..9df97ec --- /dev/null +++ b/src/test/java/com/runediary/sync/ProfileSyncServiceHiscoresTest.java @@ -0,0 +1,103 @@ +package com.runediary.sync; + +import com.google.gson.Gson; +import com.runediary.RuneDiaryConfig; +import com.runediary.model.PlayerContext; +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.game.ItemManager; +import okhttp3.Call; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that ProfileSyncService attaches the raw hi-scores CSV to the + * payload as `hiscoresRaw` when the fetch succeeds, and leaves the payload + * untouched when it fails. All parsing happens server-side. + */ +public class ProfileSyncServiceHiscoresTest +{ + private OkHttpClient httpClient; + private RuneDiaryConfig config; + private PlayerContext playerContext; + private ProfileSyncService service; + + @Before + public void setUp() + { + httpClient = mock(OkHttpClient.class); + config = mock(RuneDiaryConfig.class); + when(config.webhookUrl()).thenReturn( + "https://www.runediary.com/api/runescape-events?token=test-token" + ); + playerContext = new PlayerContext(); + playerContext.setPlayerName("TestPlayer"); + playerContext.setAccountHash("deadbeef"); + service = new ProfileSyncService( + new Gson(), + mock(Client.class), + config, + httpClient, + mock(java.util.concurrent.ScheduledExecutorService.class), + mock(ClientThread.class), + playerContext, + mock(ItemManager.class) + ); + } + + private void stubResponse(int code, String body) throws Exception + { + Call call = mock(Call.class); + Response response = new Response.Builder() + .request(new Request.Builder().url("https://secure.runescape.com/").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(code == 200 ? "OK" : "Not Found") + .body(ResponseBody.create(MediaType.parse("text/plain"), body)) + .build(); + when(call.execute()).thenReturn(response); + when(httpClient.newCall(any(Request.class))).thenReturn(call); + } + + @Test + public void attach_putsRawCsvOnPayload_whenFetchSucceeds() throws Exception + { + stubResponse(200, "1,2277,200000000\n500,99,13034431\n"); + Map payload = new HashMap<>(); + service.attachHiscoresRaw(payload, "TestPlayer", "NORMAL"); + assertEquals("1,2277,200000000\n500,99,13034431\n", payload.get("hiscoresRaw")); + } + + @Test + public void attach_isNoOp_whenFetchReturns404() throws Exception + { + stubResponse(404, ""); + Map payload = new HashMap<>(); + service.attachHiscoresRaw(payload, "TestPlayer", "NORMAL"); + assertFalse("Payload must not gain a hiscoresRaw key on failure", + payload.containsKey("hiscoresRaw")); + } + + @Test + public void attach_isNoOp_whenFetchReturnsEmptyBody() throws Exception + { + stubResponse(200, ""); + Map payload = new HashMap<>(); + service.attachHiscoresRaw(payload, "TestPlayer", "NORMAL"); + assertFalse(payload.containsKey("hiscoresRaw")); + } +}