Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/main/java/com/runediary/sync/HiscoresFetcher.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
35 changes: 33 additions & 2 deletions src/main/java/com/runediary/sync/ProfileSyncService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -461,18 +463,47 @@ 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<String, Object> 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)
{
log.error("Failed to build logout sync payload", e);
}
}

/**
* 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<String, Object> 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())
Expand Down
120 changes: 120 additions & 0 deletions src/test/java/com/runediary/sync/HiscoresFetcherTest.java
Original file line number Diff line number Diff line change
@@ -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<Request> 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<Request> 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<Request> 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<Request> 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"));
}
}
103 changes: 103 additions & 0 deletions src/test/java/com/runediary/sync/ProfileSyncServiceHiscoresTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> 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<String, Object> payload = new HashMap<>();
service.attachHiscoresRaw(payload, "TestPlayer", "NORMAL");
assertFalse(payload.containsKey("hiscoresRaw"));
}
}
Loading