{ "cells": [ { "cell_type": "code", "execution_count": 90, "id": "5c77481b", "metadata": {}, "outputs": [], "source": [ "# Requirements\n", "import numpy as np\n", "import pandas as pd\n", "import io\n", "import json\n", "\n", "\n", "# In this notebook, I take the ideas from experiments-1, and we use the quest levels to discover zones instead of using hard coded zone names. " ] }, { "cell_type": "code", "execution_count": 91, "id": "d0b3427f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Total quests for level bracket 1-3: 77\n", " Tirisfal Glades: 15 quests\n", " Mulgore: 11 quests\n", " Durotar: 20 quests\n", " Warlock: 4 quests\n", " Warrior: 5 quests\n", " Hunter: 4 quests\n", " Rogue: 4 quests\n", " Shaman: 4 quests\n", " Priest: 2 quests\n", " Mage: 3 quests\n", " Druid: 2 quests\n", " Blackrock Depths: 1 quests\n", " Uncategorized: 23 quests\n", " Darkmoon Faire: 1 quests\n", " Winter Veil: 4 quests\n", " Paladin: 2 quests\n", " Zephras Isle: 13 quests\n", "Total quests for level bracket 4-6: 126\n", " Tirisfal Glades: 32 quests\n", " Mulgore: 32 quests\n", " Durotar: 35 quests\n", " Shaman: 8 quests\n", " Warlock: 2 quests\n", " Priest: 4 quests\n", " Paladin: 2 quests\n", " Uncategorized: 72 quests\n", " Zephras Isle: 30 quests\n", "Total quests for level bracket 7-9: 85\n", " Undercity: 4 quests\n", " Tirisfal Glades: 16 quests\n", " Mulgore: 17 quests\n", " Thunder Bluff: 3 quests\n", " Durotar: 19 quests\n", " Miscellaneous: 1 quests\n", " Uncategorized: 30 quests\n", " Zephras Isle: 30 quests\n", "Total quests for level bracket 10-12: 216\n", " Tirisfal Glades: 45 quests\n", " Silverpine Forest: 17 quests\n", " Thunder Bluff: 30 quests\n", " Mulgore: 29 quests\n", " Durotar: 38 quests\n", " The Barrens: 25 quests\n", " Loch Modan: 1 quests\n", " Warlock: 6 quests\n", " Warrior: 10 quests\n", " Shaman: 11 quests\n", " Rogue: 2 quests\n", " Mage: 4 quests\n", " Priest: 12 quests\n", " Orgrimmar: 29 quests\n", " Druid: 11 quests\n", " Hunter: 19 quests\n", " Winter Veil: 1 quests\n", " Miscellaneous: 2 quests\n", " Paladin: 13 quests\n", " Uncategorized: 29 quests\n", " Zephras Isle: 41 quests\n", "Total quests for level bracket 13-15: 79\n", " Silverpine Forest: 13 quests\n", " Thunder Bluff: 7 quests\n", " Tirisfal Glades: 4 quests\n", " Mulgore: 4 quests\n", " Durotar: 6 quests\n", " The Barrens: 31 quests\n", " Undercity: 11 quests\n", " Shaman: 3 quests\n", " Rogue: 6 quests\n", " Mage: 3 quests\n", " Priest: 3 quests\n", " Ragefire Chasm: 1 quests\n", " Orgrimmar: 13 quests\n", " Druid: 5 quests\n", " Uncategorized: 7 quests\n", " Zephras Isle: 7 quests\n", "Total quests for level bracket 16-18: 76\n", " Druid: 4 quests\n", " Westfall: 5 quests\n", " Silverpine Forest: 14 quests\n", " The Barrens: 35 quests\n", " Herbalism: 1 quests\n", " Wailing Caverns: 6 quests\n", " Stonetalon Mountains: 7 quests\n", " Thunder Bluff: 10 quests\n", " Mage: 1 quests\n", " Rogue: 6 quests\n", " Ragefire Chasm: 5 quests\n", " Orgrimmar: 9 quests\n", " Undercity: 10 quests\n", " Zephras Isle: 0 quests\n", "Total quests for level bracket 19-21: 116\n", " Westfall: 2 quests\n", " Orgrimmar: 21 quests\n", " Silverpine Forest: 5 quests\n", " Hillsbrad Foothills: 3 quests\n", " Thunder Bluff: 9 quests\n", " The Barrens: 46 quests\n", " Shaman: 5 quests\n", " Stonetalon Mountains: 11 quests\n", " Warlock: 15 quests\n", " Wailing Caverns: 1 quests\n", " Warrior: 3 quests\n", " Rogue: 4 quests\n", " Priest: 6 quests\n", " Ashenvale: 7 quests\n", " Warsong Gulch: 2 quests\n", " Winter Veil: 1 quests\n", " Miscellaneous: 1 quests\n", " Uncategorized: 16 quests\n", " Mage: 1 quests\n", " Ruins of Lordaeron: 4 quests\n", " Undercity: 36 quests\n", " Druid: 8 quests\n", " Zephras Isle: 0 quests\n" ] } ], "source": [ "path = rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\resources\\quests.jsonl\"\n", "df = pd.read_json(path, lines=True)\n", "\n", "if df is None:\n", " raise Exception(\"DataFrame is None. Failed to read the CSV file.\")\n", "\n", "df.describe()\n", "\n", "horde_only_quests = df[(df[\"side\"] == \"Horde\") | (df[\"side\"] == \"Both\")]\n", "\n", "def strip_bracket_string(bracket_string):\n", " # Split the string by \": \" and take the second part\n", " parts = bracket_string.split(\": \")\n", " if len(parts) > 1:\n", " return parts[1]\n", " return bracket_string # Return the original string if it doesn't contain \": \"\n", "\n", "def locations_contains_zone(row, zone_name):\n", " locations = row.get(\"locations\")\n", " if not isinstance(locations, list):\n", " return False\n", " for location in locations:\n", " if location.get(\"role\") == \"start\" and zone_name in location[\"zone\"]:\n", " return True\n", " if location.get(\"role\") == \"end\" and zone_name in location[\"zone\"]:\n", " return True\n", " return False\n", "\n", "def get_quests_by_zone(_df: pd.DataFrame, zone_name: str) -> pd.DataFrame:\n", " location_mask = _df.apply(lambda row: locations_contains_zone(row, zone_name), axis=1)\n", " category_mask = (_df[\"category\"] == zone_name) | (_df[\"category2\"] == zone_name)\n", " return _df[location_mask | category_mask]\n", "\n", "def get_quests_by_level_bracket(_df: pd.DataFrame, min_level: int, max_level: int) -> pd.DataFrame:\n", " return _df[(_df[\"level\"] >= min_level) & (_df[\"level\"] <= max_level)]\n", "\n", "# Now, our brackets are going to be every three levels instead of 10. Let's use loops to make things\n", "# a little more efficient.\n", "index_prefix = 1\n", "brackets = {}\n", "for i in range(1, 21, 3):\n", " min_level = i\n", " max_level = i + 2\n", " bracket_quests = get_quests_by_level_bracket(horde_only_quests, min_level, max_level)\n", " print(f\"Total quests for level bracket {min_level}-{max_level}: {len(bracket_quests)}\")\n", "\n", " bracket_zones = bracket_quests[[\"category\", \"category2\"]].drop_duplicates().to_dict(orient='records')\n", " bracket_zones.append({\"category\": \"Zephras Isle\", \"category2\": \"Unknown\"}) # Add a placeholder for unknown zones\n", " \n", " \n", " for zone in bracket_zones:\n", " zone_name = zone.get(\"category\") or zone.get(\"category2\")\n", " if zone_name:\n", " zone_quests = get_quests_by_zone(bracket_quests, zone_name)\n", " print(f\" {zone_name}: {len(zone_quests)} quests\")\n", " brackets[(zone_name, f\"{index_prefix}: {min_level}-{max_level}\")] = zone_quests \n", " index_prefix += 1\n", "\n" ] }, { "cell_type": "code", "execution_count": 92, "id": "e826b3eb", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
zonebracketqueststotal_xpavg_xp
0Ashenvale7: 19-2172150307.0
1Blackrock Depths1: 1-3100.0
2Darkmoon Faire1: 1-3100.0
3Druid1: 1-3212562.0
4Druid4: 10-12114540413.0
..................
103Zephras Isle3: 7-93015090503.0
104Zephras Isle4: 10-124125465621.0
105Zephras Isle5: 13-1573045435.0
106Zephras Isle6: 16-1800NaN
107Zephras Isle7: 19-2100NaN
\n", "

108 rows × 5 columns

\n", "
" ], "text/plain": [ " zone bracket quests total_xp avg_xp\n", "0 Ashenvale 7: 19-21 7 2150 307.0\n", "1 Blackrock Depths 1: 1-3 1 0 0.0\n", "2 Darkmoon Faire 1: 1-3 1 0 0.0\n", "3 Druid 1: 1-3 2 125 62.0\n", "4 Druid 4: 10-12 11 4540 413.0\n", ".. ... ... ... ... ...\n", "103 Zephras Isle 3: 7-9 30 15090 503.0\n", "104 Zephras Isle 4: 10-12 41 25465 621.0\n", "105 Zephras Isle 5: 13-15 7 3045 435.0\n", "106 Zephras Isle 6: 16-18 0 0 NaN\n", "107 Zephras Isle 7: 19-21 0 0 NaN\n", "\n", "[108 rows x 5 columns]" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "def quest_xp(rewards) -> int:\n", " if isinstance(rewards, dict) and rewards.get(\"xp\"):\n", " return rewards[\"xp\"]\n", " return 0\n", "\n", "xp_summary = pd.DataFrame([\n", " {\"zone\": zone, \"bracket\": bracket, \"quests\": len(quests), \"total_xp\": quests[\"rewards\"].apply(quest_xp).sum()}\n", " for (zone, bracket), quests in brackets.items()\n", "])\n", "xp_summary[\"avg_xp\"] = (xp_summary[\"total_xp\"] / xp_summary[\"quests\"]).round(0)\n", "\n", "show_df = xp_summary.sort_values(by=[\"zone\", \"bracket\"]).reset_index(drop=True)\n", "display(show_df)\n", "# Now we calculate the total experience available from quests for each zone and level bracket\n", "show_df.to_csv(rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\output\\quests.csv\", index=False)" ] }, { "cell_type": "code", "execution_count": null, "id": "ab76fa6f", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
questswith_objectivetotal_xptotal_to_objectivetotal_from_objectivetotal_travelavg_travelxp_per_travel
zonebracket
Ashenvale7: 19-2173215086.286.2172.443.112.5
Blackrock Depths1: 1-31000.00.00.0NaNNaN
Darkmoon Faire1: 1-31000.00.00.0NaNNaN
Druid1: 1-3201250.00.00.0NaNinf
4: 10-1211045400.00.00.0NaNinf
..............................
Zephras Isle1: 1-3134147514.514.542.23.235.0
2: 4-6309878062.662.6251.48.434.9
3: 7-9301715090182.4183.2501.816.730.1
4: 10-12412025465251.0186.7646.016.139.4
5: 13-1573304523.123.154.010.856.4
\n", "

106 rows × 8 columns

\n", "
" ], "text/plain": [ " quests with_objective total_xp \\\n", "zone bracket \n", "Ashenvale 7: 19-21 7 3 2150 \n", "Blackrock Depths 1: 1-3 1 0 0 \n", "Darkmoon Faire 1: 1-3 1 0 0 \n", "Druid 1: 1-3 2 0 125 \n", " 4: 10-12 11 0 4540 \n", "... ... ... ... \n", "Zephras Isle 1: 1-3 13 4 1475 \n", " 2: 4-6 30 9 8780 \n", " 3: 7-9 30 17 15090 \n", " 4: 10-12 41 20 25465 \n", " 5: 13-15 7 3 3045 \n", "\n", " total_to_objective total_from_objective \\\n", "zone bracket \n", "Ashenvale 7: 19-21 86.2 86.2 \n", "Blackrock Depths 1: 1-3 0.0 0.0 \n", "Darkmoon Faire 1: 1-3 0.0 0.0 \n", "Druid 1: 1-3 0.0 0.0 \n", " 4: 10-12 0.0 0.0 \n", "... ... ... \n", "Zephras Isle 1: 1-3 14.5 14.5 \n", " 2: 4-6 62.6 62.6 \n", " 3: 7-9 182.4 183.2 \n", " 4: 10-12 251.0 186.7 \n", " 5: 13-15 23.1 23.1 \n", "\n", " total_travel avg_travel xp_per_travel \n", "zone bracket \n", "Ashenvale 7: 19-21 172.4 43.1 12.5 \n", "Blackrock Depths 1: 1-3 0.0 NaN NaN \n", "Darkmoon Faire 1: 1-3 0.0 NaN NaN \n", "Druid 1: 1-3 0.0 NaN inf \n", " 4: 10-12 0.0 NaN inf \n", "... ... ... ... \n", "Zephras Isle 1: 1-3 42.2 3.2 35.0 \n", " 2: 4-6 251.4 8.4 34.9 \n", " 3: 7-9 501.8 16.7 30.1 \n", " 4: 10-12 646.0 16.1 39.4 \n", " 5: 13-15 54.0 10.8 56.4 \n", "\n", "[106 rows x 8 columns]" ] }, "execution_count": 93, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Now we try to calculate travel requirements for each quest. We define travel as the distance from the quest giver to the objective area, and then from the objective area to the quest turn-in. For delivery/talk-to quests, we just measure straight from giver to turn-in.\n", "# An additional challenge is that the coordinates given are percentage based based off of the map frame.\n", "# Some digging online found some map size estimates, and they are the closest I have to a reliable source for now.\n", "\n", "OBJECTIVE_ROLES = (\"requirement\", \"sourcerequirement\")\n", "\n", "def is_point(coord):\n", " # Instance/dungeon zones have no map coords and are stored as [\"\"]\n", " return isinstance(coord, list) and len(coord) == 2 and all(isinstance(v, (int, float)) for v in coord)\n", "\n", "def location_center(loc):\n", " # An npc/object can have many spawn points, so use the center of all of them instead of the single representative coord\n", " points = [c for c in loc.get(\"coords\") or [] if is_point(c)]\n", " if points:\n", " return np.mean(np.array(points, dtype=float), axis=0)\n", " if is_point(loc.get(\"coord\")):\n", " return np.array(loc[\"coord\"], dtype=float)\n", " return None\n", "\n", "def zone_coords(locations, roles, zone_name):\n", " centers = [location_center(loc) for loc in locations\n", " if loc.get(\"role\") in roles and loc.get(\"zone\") == zone_name]\n", " return [c for c in centers if c is not None]\n", "\n", "def distance(a, b):\n", " if a is None or b is None:\n", " return np.nan\n", " return float(np.linalg.norm(np.subtract(a, b)))\n", "\n", "def quest_travel(row, zone_name):\n", " \n", " locations = row[\"locations\"] if isinstance(row[\"locations\"], list) else []\n", " starts = zone_coords(locations, (\"start\",), zone_name)\n", " ends = zone_coords(locations, (\"end\",), zone_name)\n", " objectives = zone_coords(locations, OBJECTIVE_ROLES, zone_name)\n", "\n", " start = starts[0] if starts else None\n", " end = ends[0] if ends else None\n", " \n", " # Objective area = mean of every objective npc/object's spawn center\n", " objective = np.mean(np.array(objectives), axis=0) if objectives else None\n", "\n", " if objective is None:\n", " # Delivery / talk-to quests: straight from giver to turn-in\n", " to_objective, from_objective = np.nan, np.nan\n", " total = distance(start, end)\n", " else:\n", " to_objective = distance(start, objective)\n", " from_objective = distance(objective, end)\n", " total = to_objective + from_objective\n", "\n", " return pd.Series({\n", " \"start_x\": start[0] if start is not None else np.nan,\n", " \"start_y\": start[1] if start is not None else np.nan,\n", " \"objective_x\": objective[0] if objective is not None else np.nan,\n", " \"objective_y\": objective[1] if objective is not None else np.nan,\n", " \"end_x\": end[0] if end is not None else np.nan,\n", " \"end_y\": end[1] if end is not None else np.nan,\n", " \"to_objective\": to_objective,\n", " \"from_objective\": from_objective,\n", " \"total_travel\": total,\n", " })\n", "\n", "def build_travel(quests: pd.DataFrame, zone_name: str, bracket: str) -> pd.DataFrame:\n", " travel = quests.apply(lambda row: quest_travel(row, zone_name), axis=1)\n", " travel.insert(0, \"zone\", zone_name)\n", " travel.insert(1, \"bracket\", bracket)\n", " travel.insert(2, \"id\", quests[\"id\"])\n", " travel.insert(3, \"name\", quests[\"name\"])\n", " travel.insert(4, \"level\", quests[\"level\"])\n", " travel.insert(5, \"xp\", quests[\"rewards\"].apply(quest_xp))\n", " return travel\n", "\n", "# Some zone + bracket entries have no quests (e.g. the Zephras Isle placeholder), and apply() on an empty frame doesn't return the travel columns\n", "travel_df = pd.concat(\n", " [build_travel(quests, zone, bracket) for (zone, bracket), quests in brackets.items() if not quests.empty],\n", " ignore_index=True,\n", ")\n", "\n", "travel_summary = travel_df.groupby([\"zone\", \"bracket\"]).agg(\n", " quests=(\"id\", \"count\"),\n", " with_objective=(\"objective_x\", \"count\"),\n", " total_xp=(\"xp\", \"sum\"),\n", " total_to_objective=(\"to_objective\", \"sum\"),\n", " total_from_objective=(\"from_objective\", \"sum\"),\n", " total_travel=(\"total_travel\", \"sum\"),\n", " avg_travel=(\"total_travel\", \"mean\"),\n", ")\n", "travel_summary[\"xp_per_travel\"] = (travel_summary[\"total_xp\"] / travel_summary[\"total_travel\"]).round(1)\n", "travel_summary.round(1)\n", "\n", "\n", "save_path = rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\output\\travel-horde.csv\"\n", "travel_summary.to_csv(save_path, index=True)" ] }, { "cell_type": "code", "execution_count": 94, "id": "7fe45672", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Ashenvale 19-21: 1 hotspots from 3 objectives\n", "Durotar 1-3: 1 hotspots from 2 objectives\n", "Durotar 4-6: 1 hotspots from 8 objectives\n", "Durotar 7-9: 1 hotspots from 6 objectives\n", "Durotar 10-12: 2 hotspots from 13 objectives\n", "Mulgore 1-3: 1 hotspots from 2 objectives\n", "Mulgore 4-6: 2 hotspots from 7 objectives\n", "Mulgore 7-9: 1 hotspots from 7 objectives\n", "Mulgore 10-12: 2 hotspots from 7 objectives\n", "Orgrimmar 10-12: 1 hotspots from 3 objectives\n", "Orgrimmar 13-15: 1 hotspots from 2 objectives\n", "Orgrimmar 19-21: 1 hotspots from 2 objectives\n", "Silverpine Forest 10-12: 1 hotspots from 5 objectives\n", "Silverpine Forest 13-15: 2 hotspots from 4 objectives\n", "Silverpine Forest 16-18: 2 hotspots from 4 objectives\n", "Silverpine Forest 19-21: 1 hotspots from 3 objectives\n", "Stonetalon Mountains 16-18: 1 hotspots from 3 objectives\n", "Stonetalon Mountains 19-21: 2 hotspots from 5 objectives\n", "The Barrens 10-12: 1 hotspots from 2 objectives\n", "The Barrens 13-15: 2 hotspots from 12 objectives\n", "The Barrens 16-18: 2 hotspots from 19 objectives\n", "The Barrens 19-21: 2 hotspots from 20 objectives\n", "Thunder Bluff 10-12: 1 hotspots from 2 objectives\n", "Tirisfal Glades 1-3: 2 hotspots from 5 objectives\n", "Tirisfal Glades 4-6: 4 hotspots from 11 objectives\n", "Tirisfal Glades 7-9: 1 hotspots from 7 objectives\n", "Tirisfal Glades 10-12: 2 hotspots from 16 objectives\n", "Tirisfal Glades 13-15: 1 hotspots from 3 objectives\n", "Westfall 19-21: 1 hotspots from 2 objectives\n", "Zephras Isle 1-3: 2 hotspots from 4 objectives\n", "Zephras Isle 4-6: 2 hotspots from 9 objectives\n", "Zephras Isle 7-9: 1 hotspots from 17 objectives\n", "Zephras Isle 10-12: 2 hotspots from 20 objectives\n", "Zephras Isle 13-15: 1 hotspots from 3 objectives\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
center_xcenter_yqueststotal_xpavg_to_objectiveavg_from_objective
zonebracketobjective_hotspot
Ashenvale7: 19-21052.748.03145028.728.7
Durotar1: 1-3042.965.124203.63.6
2: 4-6045.656.68331011.010.4
3: 7-9058.765.46399016.016.0
4: 10-12067.584.82168015.315.3
147.327.911926015.215.2
Mulgore1: 1-3046.584.424207.57.5
2: 4-6045.857.9313558.58.5
160.779.14183017.716.5
3: 7-9049.756.3748409.69.6
4: 10-12059.445.55437017.517.7
154.626.52147019.54.2
Orgrimmar4: 10-12050.957.43202522.222.2
5: 13-15043.852.42160510.310.3
7: 19-21049.750.1223004.84.8
Silverpine Forest4: 10-12054.027.15331012.613.9
5: 13-15050.934.53301013.513.5
146.072.81135033.033.0
6: 16-18068.828.62330028.527.2
156.269.42230032.332.3
7: 19-21055.876.73435038.938.9
Stonetalon Mountains6: 16-18071.388.93405029.029.0
7: 19-21056.277.93465020.322.4
166.452.32310012.812.8
The Barrens4: 10-12052.727.1218204.04.0
5: 13-15052.842.05499010.910.9
150.619.4765407.47.4
6: 16-18049.816.591123012.012.0
153.043.4101260012.212.3
7: 19-21049.651.8171745010.610.7
143.822.3359509.69.6
Thunder Bluff4: 10-12025.133.42130024.424.4
Tirisfal Glades1: 1-3050.150.3117024.824.8
132.762.846953.93.9
2: 4-6051.754.428108.18.1
161.550.2390014.73.9
235.866.8314754.94.9
326.560.838907.27.2
3: 7-9051.848.67517014.914.9
4: 10-12015.557.44314013.713.8
167.247.612879015.418.1
5: 13-15048.344.23273024.124.1
Westfall7: 19-21032.462.92300023.223.2
Zephras Isle1: 1-3046.027.235654.24.2
147.219.812502.02.0
2: 4-6045.646.0618908.28.2
137.328.4314754.54.5
3: 7-9049.461.217994510.710.8
4: 10-12061.341.28684016.414.1
160.069.312822010.06.2
5: 13-15058.041.0318207.77.7
\n", "
" ], "text/plain": [ " center_x center_y quests \\\n", "zone bracket objective_hotspot \n", "Ashenvale 7: 19-21 0 52.7 48.0 3 \n", "Durotar 1: 1-3 0 42.9 65.1 2 \n", " 2: 4-6 0 45.6 56.6 8 \n", " 3: 7-9 0 58.7 65.4 6 \n", " 4: 10-12 0 67.5 84.8 2 \n", " 1 47.3 27.9 11 \n", "Mulgore 1: 1-3 0 46.5 84.4 2 \n", " 2: 4-6 0 45.8 57.9 3 \n", " 1 60.7 79.1 4 \n", " 3: 7-9 0 49.7 56.3 7 \n", " 4: 10-12 0 59.4 45.5 5 \n", " 1 54.6 26.5 2 \n", "Orgrimmar 4: 10-12 0 50.9 57.4 3 \n", " 5: 13-15 0 43.8 52.4 2 \n", " 7: 19-21 0 49.7 50.1 2 \n", "Silverpine Forest 4: 10-12 0 54.0 27.1 5 \n", " 5: 13-15 0 50.9 34.5 3 \n", " 1 46.0 72.8 1 \n", " 6: 16-18 0 68.8 28.6 2 \n", " 1 56.2 69.4 2 \n", " 7: 19-21 0 55.8 76.7 3 \n", "Stonetalon Mountains 6: 16-18 0 71.3 88.9 3 \n", " 7: 19-21 0 56.2 77.9 3 \n", " 1 66.4 52.3 2 \n", "The Barrens 4: 10-12 0 52.7 27.1 2 \n", " 5: 13-15 0 52.8 42.0 5 \n", " 1 50.6 19.4 7 \n", " 6: 16-18 0 49.8 16.5 9 \n", " 1 53.0 43.4 10 \n", " 7: 19-21 0 49.6 51.8 17 \n", " 1 43.8 22.3 3 \n", "Thunder Bluff 4: 10-12 0 25.1 33.4 2 \n", "Tirisfal Glades 1: 1-3 0 50.1 50.3 1 \n", " 1 32.7 62.8 4 \n", " 2: 4-6 0 51.7 54.4 2 \n", " 1 61.5 50.2 3 \n", " 2 35.8 66.8 3 \n", " 3 26.5 60.8 3 \n", " 3: 7-9 0 51.8 48.6 7 \n", " 4: 10-12 0 15.5 57.4 4 \n", " 1 67.2 47.6 12 \n", " 5: 13-15 0 48.3 44.2 3 \n", "Westfall 7: 19-21 0 32.4 62.9 2 \n", "Zephras Isle 1: 1-3 0 46.0 27.2 3 \n", " 1 47.2 19.8 1 \n", " 2: 4-6 0 45.6 46.0 6 \n", " 1 37.3 28.4 3 \n", " 3: 7-9 0 49.4 61.2 17 \n", " 4: 10-12 0 61.3 41.2 8 \n", " 1 60.0 69.3 12 \n", " 5: 13-15 0 58.0 41.0 3 \n", "\n", " total_xp avg_to_objective \\\n", "zone bracket objective_hotspot \n", "Ashenvale 7: 19-21 0 1450 28.7 \n", "Durotar 1: 1-3 0 420 3.6 \n", " 2: 4-6 0 3310 11.0 \n", " 3: 7-9 0 3990 16.0 \n", " 4: 10-12 0 1680 15.3 \n", " 1 9260 15.2 \n", "Mulgore 1: 1-3 0 420 7.5 \n", " 2: 4-6 0 1355 8.5 \n", " 1 1830 17.7 \n", " 3: 7-9 0 4840 9.6 \n", " 4: 10-12 0 4370 17.5 \n", " 1 1470 19.5 \n", "Orgrimmar 4: 10-12 0 2025 22.2 \n", " 5: 13-15 0 1605 10.3 \n", " 7: 19-21 0 2300 4.8 \n", "Silverpine Forest 4: 10-12 0 3310 12.6 \n", " 5: 13-15 0 3010 13.5 \n", " 1 1350 33.0 \n", " 6: 16-18 0 3300 28.5 \n", " 1 2300 32.3 \n", " 7: 19-21 0 4350 38.9 \n", "Stonetalon Mountains 6: 16-18 0 4050 29.0 \n", " 7: 19-21 0 4650 20.3 \n", " 1 3100 12.8 \n", "The Barrens 4: 10-12 0 1820 4.0 \n", " 5: 13-15 0 4990 10.9 \n", " 1 6540 7.4 \n", " 6: 16-18 0 11230 12.0 \n", " 1 12600 12.2 \n", " 7: 19-21 0 17450 10.6 \n", " 1 5950 9.6 \n", "Thunder Bluff 4: 10-12 0 1300 24.4 \n", "Tirisfal Glades 1: 1-3 0 170 24.8 \n", " 1 695 3.9 \n", " 2: 4-6 0 810 8.1 \n", " 1 900 14.7 \n", " 2 1475 4.9 \n", " 3 890 7.2 \n", " 3: 7-9 0 5170 14.9 \n", " 4: 10-12 0 3140 13.7 \n", " 1 8790 15.4 \n", " 5: 13-15 0 2730 24.1 \n", "Westfall 7: 19-21 0 3000 23.2 \n", "Zephras Isle 1: 1-3 0 565 4.2 \n", " 1 250 2.0 \n", " 2: 4-6 0 1890 8.2 \n", " 1 1475 4.5 \n", " 3: 7-9 0 9945 10.7 \n", " 4: 10-12 0 6840 16.4 \n", " 1 8220 10.0 \n", " 5: 13-15 0 1820 7.7 \n", "\n", " avg_from_objective \n", "zone bracket objective_hotspot \n", "Ashenvale 7: 19-21 0 28.7 \n", "Durotar 1: 1-3 0 3.6 \n", " 2: 4-6 0 10.4 \n", " 3: 7-9 0 16.0 \n", " 4: 10-12 0 15.3 \n", " 1 15.2 \n", "Mulgore 1: 1-3 0 7.5 \n", " 2: 4-6 0 8.5 \n", " 1 16.5 \n", " 3: 7-9 0 9.6 \n", " 4: 10-12 0 17.7 \n", " 1 4.2 \n", "Orgrimmar 4: 10-12 0 22.2 \n", " 5: 13-15 0 10.3 \n", " 7: 19-21 0 4.8 \n", "Silverpine Forest 4: 10-12 0 13.9 \n", " 5: 13-15 0 13.5 \n", " 1 33.0 \n", " 6: 16-18 0 27.2 \n", " 1 32.3 \n", " 7: 19-21 0 38.9 \n", "Stonetalon Mountains 6: 16-18 0 29.0 \n", " 7: 19-21 0 22.4 \n", " 1 12.8 \n", "The Barrens 4: 10-12 0 4.0 \n", " 5: 13-15 0 10.9 \n", " 1 7.4 \n", " 6: 16-18 0 12.0 \n", " 1 12.3 \n", " 7: 19-21 0 10.7 \n", " 1 9.6 \n", "Thunder Bluff 4: 10-12 0 24.4 \n", "Tirisfal Glades 1: 1-3 0 24.8 \n", " 1 3.9 \n", " 2: 4-6 0 8.1 \n", " 1 3.9 \n", " 2 4.9 \n", " 3 7.2 \n", " 3: 7-9 0 14.9 \n", " 4: 10-12 0 13.8 \n", " 1 18.1 \n", " 5: 13-15 0 24.1 \n", "Westfall 7: 19-21 0 23.2 \n", "Zephras Isle 1: 1-3 0 4.2 \n", " 1 2.0 \n", " 2: 4-6 0 8.2 \n", " 1 4.5 \n", " 3: 7-9 0 10.8 \n", " 4: 10-12 0 14.1 \n", " 1 6.2 \n", " 5: 13-15 0 7.7 " ] }, "execution_count": 94, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Afterwards, we use X-means clustering to find hotspots for quest objective locations.\n", "# X-means = k-means that keeps splitting clusters in two while the split improves the\n", "# BIC score (Pelleg & Moore, 2000), so we don't have to pick k ourselves.\n", "\n", "def kmeans(points: np.ndarray, centers: np.ndarray, iterations: int = 100):\n", " for _ in range(iterations):\n", " labels = np.argmin(np.linalg.norm(points[:, None] - centers[None], axis=2), axis=1)\n", " new_centers = np.array([\n", " points[labels == k].mean(axis=0) if np.any(labels == k) else centers[k]\n", " for k in range(len(centers))\n", " ])\n", " if np.allclose(new_centers, centers):\n", " break\n", " centers = new_centers\n", " return labels, centers\n", "\n", "def bic(points: np.ndarray, labels: np.ndarray, centers: np.ndarray, min_variance: float = 1.25) -> float:\n", " r, m = points.shape\n", " k = len(centers)\n", " if r <= k:\n", " return -np.inf\n", " # Many quests share the exact same objective coord, so without a floor a tiny cluster gets ~0 variance and an infinite likelihood. 1.0 = roughly 1% of the map.\n", " variance = max(np.sum((points - centers[labels]) ** 2) / (m * (r - k)), min_variance)\n", " log_likelihood = 0.0\n", " for i in range(k):\n", " r_i = np.sum(labels == i)\n", " if r_i == 0:\n", " continue\n", " log_likelihood += (r_i * np.log(r_i) - r_i * np.log(r)\n", " - r_i * m / 2 * np.log(2 * np.pi * variance)\n", " - (r_i - 1) * m / 2)\n", " free_params = k * (m + 1)\n", " return log_likelihood - free_params / 2 * np.log(r)\n", "\n", "def best_split(members: np.ndarray, rng, attempts: int):\n", " # k-means only finds a local optimum, so try a few random starts and keep the best split\n", " best_score, best_children = -np.inf, None\n", " for _ in range(attempts):\n", " child_labels, children = kmeans(members, members[rng.choice(len(members), 2, replace=False)])\n", " score = bic(members, child_labels, children)\n", " if score > best_score:\n", " best_score, best_children = score, children\n", " return best_score, best_children\n", "\n", "def xmeans(points: np.ndarray, k_max: int = 15, split_attempts: int = 10, seed: int = 0):\n", " rng = np.random.default_rng(seed)\n", " labels, centers = kmeans(points, points.mean(axis=0, keepdims=True))\n", " score = bic(points, labels, centers)\n", " while True:\n", " new_centers = []\n", " for k, center in enumerate(centers):\n", " members = points[labels == k]\n", " if len(members) < 4:\n", " new_centers.append(center)\n", " continue\n", " parent_score = bic(members, np.zeros(len(members), dtype=int), center[None])\n", " child_score, children = best_split(members, rng, split_attempts)\n", " new_centers.extend(children if child_score > parent_score else [center])\n", " if len(new_centers) == len(centers) or len(new_centers) > k_max:\n", " return labels, centers\n", " # The per-cluster test is lenient on small clusters, so only keep the splits if they also improve the BIC of the whole zone\n", " new_labels, new_centers = kmeans(points, np.array(new_centers))\n", " new_score = bic(points, new_labels, new_centers)\n", " if new_score <= score:\n", " return labels, centers\n", " labels, centers, score = new_labels, new_centers, new_score\n", "\n", "# Cluster each zone + bracket's objective areas separately, then classify every questin the travel data by the hotspot its objective falls in.\n", "# Hotspot ids are only unique within a zone + bracket.\n", "travel_df[\"objective_hotspot\"] = pd.NA\n", "travel_df[\"hotspot_distance\"] = np.nan\n", "hotspots = {}\n", "\n", "for (zone_name, bracket), bracket_travel in travel_df.groupby([\"zone\", \"bracket\"]):\n", " has_objective = bracket_travel.dropna(subset=[\"objective_x\", \"objective_y\"])\n", " points = has_objective[[\"objective_x\", \"objective_y\"]].to_numpy()\n", " if len(points) < 2:\n", " continue\n", " labels, centers = xmeans(points)\n", " hotspots[(zone_name, bracket)] = centers\n", " travel_df.loc[has_objective.index, \"objective_hotspot\"] = labels\n", " travel_df.loc[has_objective.index, \"hotspot_distance\"] = np.linalg.norm(points - centers[labels], axis=1)\n", " print(f\"{zone_name} {strip_bracket_string(bracket)}: {len(centers)} hotspots from {len(points)} objectives\")\n", "\n", "hotspot_summary = travel_df.dropna(subset=[\"objective_hotspot\"]).groupby([\"zone\", \"bracket\", \"objective_hotspot\"]).agg(\n", " center_x=(\"objective_x\", \"mean\"),\n", " center_y=(\"objective_y\", \"mean\"),\n", " quests=(\"id\", \"count\"),\n", " total_xp=(\"xp\", \"sum\"),\n", " avg_to_objective=(\"to_objective\", \"mean\"),\n", " avg_from_objective=(\"from_objective\", \"mean\"),\n", ")\n", "hotspot_summary.round(1)" ] }, { "cell_type": "code", "execution_count": 95, "id": "64996499", "metadata": {}, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "data": [ { "colorbar": { "outlinewidth": 0, "thickness": 12, "title": { "text": "Total XP" } }, "colorscale": [ [ 0, "#cde2fb" ], [ 0.16666666666666666, "#9ec5f4" ], [ 0.3333333333333333, "#6da7ec" ], [ 0.5, "#3987e5" ], [ 0.6666666666666666, "#256abf" ], [ 0.8333333333333334, "#184f95" ], [ 1, "#0d366b" ] ], "hoverinfo": "skip", "text": [ [ "1,165 XP
11 quests
avg 106", "10,420 XP
32 quests
avg 326", "8,725 XP
17 quests
avg 513", "16,585 XP
29 quests
avg 572", "2,120 XP
4 quests
avg 530", "", "" ], [ "1,470 XP
15 quests
avg 98", "9,940 XP
32 quests
avg 311", "8,805 XP
16 quests
avg 550", "24,840 XP
45 quests
avg 552", "2,835 XP
4 quests
avg 709", "", "" ], [ "1,540 XP
20 quests
avg 77", "10,525 XP
35 quests
avg 301", "12,045 XP
19 quests
avg 634", "21,430 XP
38 quests
avg 564", "4,005 XP
6 quests
avg 668", "", "" ], [ "1,475 XP
13 quests
avg 113", "8,780 XP
30 quests
avg 293", "15,090 XP
30 quests
avg 503", "25,465 XP
41 quests
avg 621", "3,045 XP
7 quests
avg 435", "", "" ] ], "textfont": { "size": 12 }, "texttemplate": "%{text}", "type": "heatmap", "x": [ "1-3", "4-6", "7-9", "10-12", "13-15", "16-18", "19-21" ], "xgap": 2, "y": [ "Mulgore", "Tirisfal Glades", "Durotar", "Zephras Isle" ], "ygap": 2, "z": { "bdata": "AAAAAAA0kkAAAAAAAFrEQAAAAACACsFAAAAAAEAy0EAAAAAAAJCgQAAAAAAAAPh/AAAAAAAA+H8AAAAAAPiWQAAAAAAAasNAAAAAAIAywUAAAAAAAELYQAAAAAAAJqZAAAAAAAAA+H8AAAAAAAD4fwAAAAAAEJhAAAAAAICOxEAAAAAAgIbHQAAAAACA7dRAAAAAAABKr0AAAAAAAAD4fwAAAAAAAPh/AAAAAAAMl0AAAAAAACbBQAAAAAAAec1AAAAAAEDe2EAAAAAAAMqnQAAAAAAAAPh/AAAAAAAA+H8=", "dtype": "f8", "shape": "4, 7" } } ], "layout": { "font": { "color": "#0b0b0b", "family": "system-ui, -apple-system, Segoe UI, sans-serif", "size": 12 }, "height": 480, "margin": { "b": 40, "l": 10, "r": 10, "t": 60 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "white", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "white", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "#C8D4E3", "linecolor": "#C8D4E3", "minorgridcolor": "#C8D4E3", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "#C8D4E3", "linecolor": "#C8D4E3", "minorgridcolor": "#C8D4E3", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "sequentialminus": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "white", "showlakes": true, "showland": true, "subunitcolor": "#C8D4E3" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "paper_bgcolor": "white", "plot_bgcolor": "white", "polar": { "angularaxis": { "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "" }, "bgcolor": "white", "radialaxis": { "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" }, "yaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" }, "zaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" }, "baxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" }, "bgcolor": "white", "caxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "#EBF0F8", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "#EBF0F8", "zerolinewidth": 2 } } }, "title": { "text": "Quest XP by zone and level bracket (Horde)" }, "xaxis": { "showgrid": false, "side": "top", "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Chart the XP summary as a heatmap of total XP per zone + bracket, with each cell's stats printed on it.\n", "import plotly.graph_objects as go\n", "\n", "SURFACE = \"#fcfcfb\"\n", "INK = \"#0b0b0b\"\n", "MUTED = \"#898781\"\n", "# Single-hue sequential ramp, light = little XP, dark = lots\n", "XP_RAMP = [\"#cde2fb\", \"#9ec5f4\", \"#6da7ec\", \"#3987e5\", \"#256abf\", \"#184f95\", \"#0d366b\"]\n", "\n", "chart_layout = dict(\n", " template=\"plotly_white\",\n", " paper_bgcolor=SURFACE,\n", " plot_bgcolor=SURFACE,\n", " font=dict(family=\"system-ui, -apple-system, Segoe UI, sans-serif\", color=INK, size=12),\n", " margin=dict(l=10, r=10, t=60, b=40),\n", ")\n", "\n", "chart_df = xp_summary.copy()\n", "# Bracket keys look like \"1: 1-3\", the prefix only exists to sort them, so show just the level range\n", "bracket_order = sorted(chart_df[\"bracket\"].unique())\n", "bracket_labels = [strip_bracket_string(b) for b in bracket_order]\n", "\n", "target_zones = [\"Durotar\", \"Mulgore\", \"Tirisfal Glades\", \"Zephras Isle\"]\n", "chart_df = chart_df[chart_df[\"zone\"].isin(target_zones)].copy()\n", "# Ascending so the zone with the most XP ends up at the top of the chart\n", "zone_totals = chart_df.groupby(\"zone\")[\"total_xp\"].sum().sort_values()\n", "\n", "def zone_grid(column):\n", " return chart_df.pivot(index=\"zone\", columns=\"bracket\", values=column).reindex(index=zone_totals.index, columns=bracket_order)\n", "\n", "quest_grid = zone_grid(\"quests\")\n", "avg_grid = zone_grid(\"avg_xp\")\n", "# Brackets with no quests (e.g. the Zephras Isle placeholder) are left blank rather than shaded as 0 XP\n", "xp_grid = zone_grid(\"total_xp\").where(quest_grid > 0)\n", "\n", "def cell_label(xp, quests, avg):\n", " if pd.isna(quests) or quests == 0:\n", " return \"\"\n", " return f\"{xp:,.0f} XP
{quests:.0f} quests
avg {avg:,.0f}\"\n", "\n", "# Stats go on the cells themselves instead of a tooltip, so the chart reads in a screenshot\n", "cell_text = [\n", " [cell_label(xp, quests, avg) for xp, quests, avg in zip(xp_row, quest_row, avg_row)]\n", " for xp_row, quest_row, avg_row in zip(xp_grid.to_numpy(), quest_grid.to_numpy(), avg_grid.to_numpy())\n", "]\n", "\n", "heatmap = go.Figure(go.Heatmap(\n", " z=xp_grid.to_numpy(),\n", " x=bracket_labels,\n", " y=xp_grid.index,\n", " text=cell_text,\n", " texttemplate=\"%{text}\",\n", " textfont=dict(size=12),\n", " hoverinfo=\"skip\",\n", " colorscale=[[i / (len(XP_RAMP) - 1), c] for i, c in enumerate(XP_RAMP)],\n", " xgap=2, ygap=2,\n", " colorbar=dict(title=\"Total XP\", outlinewidth=0, thickness=12),\n", "))\n", "heatmap.update_layout(\n", " **chart_layout,\n", " title=\"Quest XP by zone and level bracket (Horde)\",\n", " # Rows need room for three lines of text\n", " height=90 * len(xp_grid) + 120,\n", " # Without type=\"category\", plotly reads labels like \"1-3\" as dates\n", " xaxis=dict(type=\"category\", side=\"top\", showgrid=False, tickfont_color=MUTED),\n", " yaxis=dict(type=\"category\", showgrid=False, tickfont_color=MUTED),\n", ")\n", "heatmap.show()" ] } ], "metadata": { "kernelspec": { "display_name": ".venv (3.14.6)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.14.6" } }, "nbformat": 4, "nbformat_minor": 5 }