Introduction

suppressPackageStartupMessages(library(tidyverse))
suppressPackageStartupMessages(library(probabilities))
suppressPackageStartupMessages(library(jsonlite))
suppressPackageStartupMessages(library(glue))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(kableExtra))

json_data <-
  glue("~/.runelite/raid-data tracker/cox/raid_tracker_data.log") %>% 
  readLines() %>%
  map(fromJSON)

cox_df <-
  json_data %>%
  map(list_modify, "lootList" = NULL) %>%
  map(bind_cols) %>%
  bind_rows() %>%
  arrange(date) %>% 
  mutate(loot = map(json_data, chuck, "lootList"),
         date = as_datetime(date / 1000),
         across(where(is.character), function(x) if_else(x == "", NA, x)))

The Chambers of Xeric has a large amount of unique drops, leading to significant variance in the number of completions required to “finish” the raid. As a result, it can be hard to tell if you’re dry, spooned, or simply on rate when hunting that one item. This document provides some analysis of my CoX grind so far, and it gives some perspective on the grind I can look forward to while going for the twisted bow.

If you’re curious about how I’m making these tables, you can take a peek at the code using the “Show” buttons above and to the right of each table. In the chunk above I’m just loading some libraries and importing the data generated by the Raid Data Tracker. The probabilities package is a homemade set of functions to make calculating and formatting probabilities easier.

A lot of the styling used in this document is borrowed from the Old School RuneScape Wiki, helping to give it that classic OSRS look.

The uniques I’ve received

Chambers is fun, but these are why we’re here after all. The table below shows what uniques I’ve received, and at what completions. The last column shows the cumulative chance I had of getting that particular unique by the time I got it the first time.

received_items <- 
  cox_df %>% 
  filter(!is.na(specialLoot)) %>% 
  distinct(specialLoot) %>% 
  pull(specialLoot) %>% 
  tolower()

cox_df %>% 
  filter(!is.na(specialLoot)) %>% 
  group_by(specialLoot) %>% 
  summarise(kc = paste(completionCount, collapse = "; ")) %>% 
  select(specialLoot, kc) %>% 
  left_join(
    do.call(c, ancient_chest) %>%
      enframe(name = "item", value = "probability") %>%
      mutate(item = stringr::str_to_sentence(item)) %>% 
      filter(item %in% stringr::str_to_sentence(received_items)) %>%
      cross_join(cox_df) %>%
      group_by(item) %>%
      filter(date <= date[min(which(specialLoot == item))]) %>%
      summarise(p = disjunction.inclusive(cox_purple(personalPoints) * probability)),
    join_by(specialLoot == item)
  ) %>%
  kable(col.names = c("Unique", "Completions", "Chance")) %>% 
  kable_styling(full_width = FALSE)
Unique Completions Chance
Ancestral hat 101; 507 11%
Ancestral robe bottom 406 43%
Ancestral robe top 134 15%
Arcane prayer scroll 162; 340; 422; 680; 943; 955 75%
Dexterous prayer scroll 404; 421; 442; 635; 721; 753; 820; 881 98%
Dinh’s bulwark 583 56%
Dragon claws 392; 892 42%
Dragon hunter crossbow 470 58%
Elder maul 211 17%
Kodai insignia 45; 460 3%
Twisted bow 978 62%

The uniques I’m still missing

Sometimes you don’t get the item you want. The table below shows how likely I’ve been to receive the items I’m missing by my current number of completions and personal points.

cox_df %>%
  mutate(year  = year(date),
         month = glue("{year}-m{month}",
                      month = month(date)),
         week = glue("{year}-w{week}", week = isoweek(date)),
         date  = date(date)) %>%
  select(year, month, week, date, personalPoints) %>%
  cross_join(
    ancient_chest %>%
      do.call(what = c) %>%
      enframe(name = "item", value = "probability")
  ) %>%
  mutate(probability = cox_item(personalPoints, probability)) %>%
  data.table::as.data.table() %>%
  data.table::groupingsets(
    j = list(probability = disjunction.inclusive(probability)),
    by = c("year", "month", "week", "date", "item"),
    sets = list("item")
  ) %>%
  as_tibble() %>%
  select(item, probability) %>% 
  filter(!item %in% received_items) %>% 
  mutate(item = str_to_sentence(item)) %>% 
  kable(col.names = c("Unique", "Chance")) %>% 
  kable_styling(full_width = FALSE)
Unique Chance
Twisted buckler 85%

The purples I’ve received

purple_df <- 
  cox_df %>%
  select(date, personalPoints, specialLoot) %>%
  mutate(
    kc = row_number(),
    purple = if_else(is.na(specialLoot), FALSE, TRUE),
    purple = c(1, cumsum(purple)[1:(n()-1)] + 1)
  ) %>%
  group_by(purple) %>%
  summarise(
    p = disjunction.inclusive(map_vec(personalPoints, cox_purple)),
    kc = n(),
    points = format(sum(personalPoints), big.mark = ",")
  ) %>% 
  select(purple, kc, points, p)

n_purples <- max(purple_df$purple) - 1

completions_since_last_purple <- purple_df$kc[max(purple_df$purple)]

points_since_last_purple <- purple_df$points[max(purple_df$purple)]

prob_since_last_purple <- purple_df$p[max(purple_df$purple)]

You go more dry for some purples than others. The table below shows precisely how dry I went for mine. The last column gives the chance of having gotten that particular purple by the time I ended up getting it, starting from 0% after the purple before it.

So far, I have received 25 purples. Since my last purple, I have:

Given my total number of completions and personal points, I’m expected to have seen 33 purples by now.

purple_df %>% 
  filter(purple != max(purple)) %>% 
  kable(col.names = c("Purple #", "Completions", 
                      "Personal points", "Chance"),
        align = "r") %>% 
  kable_styling(full_width = FALSE)
Purple # Completions Personal points Chance
1 33 799,760 61%
2 56 1,592,012 85%
3 33 956,922 67%
4 28 815,727 62%
5 49 1,376,727 80%
6 129 3,746,262 99%
7 52 1,426,448 81%
8 12 337,779 33%
9 2 57,334 7%
10 15 428,784 40%
11 1 29,947 3%
12 20 575,643 49%
13 18 531,043 46%
14 10 297,371 29%
15 37 1,128,687 73%
16 76 2,293,353 93%
17 52 1,543,305 84%
18 45 1,368,422 80%
19 41 1,248,138 77%
20 32 956,018 67%
21 68 2,143,214 92%
22 66 2,119,447 92%
23 11 349,780 34%
24 51 1,599,214 85%
25 12 370,872 35%
completions_cmonth <- 
  cox_df %>%
  filter(date >= Sys.Date() %m-% months(1)) %>%
  mutate(date = date(date)) %>%
  select(date, personalPoints)

chances_cmonth <-
  completions_cmonth %>%
  cross_join(
    ancient_chest %>%
      do.call(what = c) %>%
      enframe(name = "item", value = "probability")
  ) %>%
  mutate(probability = cox_item(personalPoints, probability)) %>%
  data.table::as.data.table() %>%
  data.table::groupingsets(
    j = list(probability = disjunction.inclusive(probability),
             kc = .N,
             personalPoints = sum(personalPoints)),
    by = c("date", "item"),
    sets = list(c("date", "item"),
                "item")
  ) %>%
  as_tibble()

performance_cmonth <-
  completions_cmonth %>%
  data.table::as.data.table() %>%
  data.table::groupingsets(
    j = list(personalPoints = sum(personalPoints),
             kc = .N),
    by = c("date"),
    sets = list("date", character())
  ) %>%
  as_tibble() %>% 
  filter(!is.na(date)) %>% 
  full_join(
    tibble(date = seq.Date(from = Sys.Date() %m-% months(1),
                           to  = Sys.Date(),
                           by = "days")),
    join_by(date)
  ) %>% 
  mutate(across(c(personalPoints, kc), \(x) if_else(is.na(x), 0, x))) %>% 
  arrange(date)

The raids I’ve completed

total_raids <- max(cox_df$completionCount)

start_date <- date(cox_df$date[cox_df$date == min(cox_df$date)])

max_raids <- 
  cox_df %>%
  count(date = date(date)) %>% 
  filter(n == max(n)) %>% 
  pull(n)

max_raids_date <- 
  cox_df %>%
  count(date = date(date)) %>% 
  filter(n == max(n)) %>% 
  pull(date)

pb_time <-
  cox_df %>% 
  filter(raidTime == min(raidTime)) %>% 
  pull(raidTime) %>% 
  seconds_to_period()

I’ve done 978 raids in total, starting on 2022-12-04. The most raids I ever did in a single day was 18 on 2024-04-01. My current personal best is 21 minutes and 46 seconds.

The figure below shows the timeline of my completions in the Chamber of Xeric, starting from the 13th completion. The shapes arranged on the timeline represent the items I’ve received along the way.

cox_df %>% 
  select(completionCount, date, specialLoot, challengeMode) %>% 
  mutate(challengeMode = if_else(challengeMode, "Challenge mode", "Regular")) %>% 
  ggplot(mapping = aes(x = date, y = completionCount)) +
  geom_line(
    mapping = aes(color = challengeMode)
  ) +
  geom_point(
    data = ~ 
      filter(.x, !is.na(specialLoot)) %>% 
      left_join(
        enframe(x = do.call(c, ancient_chest), 
                name = "item", 
                value = "probability") %>%
        mutate(
          group = 
            case_when(
              probability == p(20/69) ~ "Prayer scroll",
              probability == p(4/69)  ~ "Buckler and dhcb",
              probability == p(3/69)  ~ "Ancestral, claws and bulwark",
              probability == p(2/69)  ~ "Megarare"
            ),
          item = stringr::str_to_sentence(item)
        ),
        join_by(specialLoot == item)
      ),
      mapping = aes(shape = group), 
      color = "purple",
      size = 3
  ) +
  theme_bw() +
  theme(panel.background = element_rect(fill = "#d8ccb4"),
        plot.background = element_rect(fill = "#d8ccb4", color = "#d8ccb4"),
        plot.title = element_text(hjust = 0.5),
        panel.grid.major = element_line(color = "gray"),
        panel.grid.minor = element_line(color = "gray"),
        legend.background = element_rect(fill = "#d8ccb4"),
        legend.key = element_rect(fill = "#e2dbc8", color = "gray"),
        axis.title.x = element_blank(),
        legend.position = "top") +
  labs(title = "Completions over time") +
  guides(shape = guide_legend(title = element_blank()),
         color = guide_legend(title = element_blank())) +
  scale_shape(solid = FALSE) + 
  ylab("Completions") +
  xlab("Date")

The grind I’m facing

average_daily_kc <-
  performance_cmonth %>% 
  summarise(across(c(personalPoints, kc), \(x) sum(x))) %>% 
  mutate(personalPoints = personalPoints / kc,
         kc             = kc / nrow(performance_cmonth)) %>% 
  pull(kc) %>% 
  round(digits = 1)

average_points <- 
  cox_df %>% 
  filter(date >= Sys.Date() %m-% months(1)) %>%
  pull(personalPoints) %>% 
  mean() %>% 
  round(digits = -2)

average_time <- 
  cox_df %>% 
  filter(date >= Sys.Date() %m-% months(1)) %>% 
  pull(raidTime) %>% 
  mean() %>% 
  round(digits = 0) %>% 
  seconds_to_period()

That twisted bow could be right around the corner! During the last month I averaged:

The table below shows how likely I am to get the items I’m still missing by various intervals, if I keep up the current pace.

intervals <- tibble(months = c(1, 2, 3, 6, 12, 24))

chances_cmonth %>% 
  filter(is.na(date), !item %in% received_items) %>% 
  cross_join(intervals) %>% 
  mutate(p = disjunction.inclusive.n(probability, months),
         months = case_when(months == 1 ~ "1 month",
                            TRUE        ~ paste(months, "months")),
         item = str_to_sentence(item)) %>% 
  select(Unique = item, months, p) %>% 
  pivot_wider(names_from  = months,
              values_from = p) %>% 
  knitr::kable() %>% 
  kable_styling(full_width = FALSE)
Unique 1 month 2 months 3 months 6 months 12 months 24 months
Twisted buckler 14% 27% 37% 61% 85% 98%