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.
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% |
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% |
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)
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")
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% |