diff --git a/Dockerfile b/Dockerfile index 5746e97..c9701e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY app.py db.py parser.py categories.py validation.py viewers.py security.py ./ +COPY app.py db.py parser.py categories.py validation.py viewers.py security.py game_data.py advisor.py ./ +COPY game_data/ game_data/ COPY templates/ templates/ COPY static/ static/ diff --git a/advisor.py b/advisor.py new file mode 100644 index 0000000..5724dce --- /dev/null +++ b/advisor.py @@ -0,0 +1,120 @@ +"""Skill training recommendations from game recipe data + save state.""" + +from __future__ import annotations + +from typing import Any + +from game_data import format_display_name, manifest, recipes_for_skill, supported_advisor_skills, xp_per_minute + + +def _inventory_map(snapshot: dict[str, Any]) -> dict[str, int]: + inv: dict[str, int] = {} + for item in snapshot.get("inventory") or []: + key = item.get("key") + if key: + inv[str(key)] = int(item.get("qty") or 0) + return inv + + +def _skill_state(snapshot: dict[str, Any], skill_key: str) -> dict[str, Any] | None: + for skill in snapshot.get("skills") or []: + if skill.get("key") == skill_key: + return skill + return None + + +def missing_materials( + materials: dict[str, int], + inventory: dict[str, int], +) -> dict[str, int]: + missing: dict[str, int] = {} + for item_key, needed in materials.items(): + need = int(needed) + have = inventory.get(item_key, 0) + if have < need: + missing[item_key] = need - have + return missing + + +def crafts_to_next_level(xp_remaining: int, xp_per_item: float) -> int: + if xp_remaining <= 0 or xp_per_item <= 0: + return 1 + return max(1, int((xp_remaining + xp_per_item - 1) // xp_per_item)) + + +def advise_skill( + skill_key: str, + snapshot: dict[str, Any], + *, + limit: int = 8, +) -> dict[str, Any]: + skill_key = skill_key.strip().lower() + recipes = recipes_for_skill(skill_key) + if not recipes: + return { + "skill_key": skill_key, + "supported": False, + "error": "unsupported_skill", + "supported_skills": supported_advisor_skills(), + } + + skill = _skill_state(snapshot, skill_key) + if not skill: + return { + "skill_key": skill_key, + "supported": True, + "error": "skill_not_in_save", + } + + level = int(skill.get("level") or 1) + xp_in_level = int(skill.get("xp_in_level") or 0) + xp_needed = int(skill.get("xp_needed") or 1) + xp_remaining = max(0, xp_needed - xp_in_level) + inventory = _inventory_map(snapshot) + + candidates: list[dict[str, Any]] = [] + for activity_key, recipe in recipes.items(): + level_required = int(recipe.get("level_required") or 1) + if level_required > level: + continue + materials = {str(k): int(v) for k, v in (recipe.get("materials") or {}).items()} + missing = missing_materials(materials, inventory) + rate = xp_per_minute(recipe) + if rate <= 0: + continue + eta_minutes = int((xp_remaining + rate - 1) // rate) if xp_remaining > 0 else 0 + craft_count = crafts_to_next_level(xp_remaining, float(recipe.get("xp_per_item") or 0)) + candidates.append({ + "activity_key": activity_key, + "display_name": format_display_name(recipe, activity_key), + "type": recipe.get("type"), + "level_required": level_required, + "xp_per_item": float(recipe.get("xp_per_item") or 0), + "time_per_item": int(recipe.get("time_per_item") or 60), + "xp_per_minute": round(rate, 2), + "materials": materials, + "can_craft": not missing, + "missing_materials": missing, + "eta_minutes_to_level": eta_minutes, + "crafts_to_next_level": craft_count, + }) + + candidates.sort( + key=lambda row: ( + 0 if row["can_craft"] else 1, + -row["xp_per_minute"], + row["level_required"], + ), + ) + + meta = manifest() + return { + "skill_key": skill_key, + "skill_name": skill.get("name") or skill_key.replace("_", " ").title(), + "skill_level": level, + "xp_remaining_in_level": xp_remaining, + "supported": True, + "game_data_sha": meta.get("source_sha"), + "game_data_synced_at": meta.get("synced_at"), + "recommendations": candidates[:limit], + } diff --git a/app.py b/app.py index b2fa86a..b7d4fc9 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ from pathlib import Path from flask import Blueprint, Flask, abort, jsonify, render_template, request, send_file, send_from_directory from werkzeug.utils import secure_filename +from advisor import advise_skill from db import ( DEFAULT_DB, create_goal, @@ -195,6 +196,15 @@ def api_combat_timeline(viewer_id: str): return jsonify(combat_timeline(db_path=db_path)) +@viewer_bp.route("/api/advisor/") +def api_skill_advisor(viewer_id: str, skill_key: str): + db_path = _resolve_viewer_db(viewer_id) + snapshot = get_latest_snapshot(db_path=db_path) + if not snapshot: + return jsonify({"error": "No snapshots imported yet"}), 404 + return jsonify(advise_skill(skill_key, snapshot)) + + @viewer_bp.route("/api/goals/overview") def api_goals_overview(viewer_id: str): db_path = _resolve_viewer_db(viewer_id) diff --git a/db.py b/db.py index 9c515f6..cd84fa8 100644 --- a/db.py +++ b/db.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from categories import categorize_item from parser import SaveParseError, normalize_save, load_save DEFAULT_DB = Path(__file__).parent / "data" / "history.db" @@ -722,16 +723,25 @@ def _resolve_item_from_latest( "SELECT item_key, qty, category FROM inventory_snapshots WHERE snapshot_id = ? AND item_key = ?", (snapshot_id, item_key), ).fetchone() - if not row: - return None - latest = get_latest_snapshot(conn=conn) - item_name = item_key.replace("_", " ").title() - if latest: - for item in latest.get("inventory", []): - if item["key"] == item_key: - item_name = item["name"] - break - return {"item_key": item_key, "item_name": item_name, "category": row["category"], "qty": row["qty"]} + if row: + latest = get_latest_snapshot(conn=conn) + item_name = item_key.replace("_", " ").title() + if latest: + for item in latest.get("inventory", []): + if item["key"] == item_key: + item_name = item["name"] + break + return {"item_key": item_key, "item_name": item_name, "category": row["category"], "qty": row["qty"]} + + from game_data import recipe_display_name + + display = recipe_display_name(item_key) + return { + "item_key": item_key, + "item_name": display or item_key.replace("_", " ").title(), + "category": categorize_item(item_key), + "qty": 0, + } def create_goal( diff --git a/game_data.py b/game_data.py new file mode 100644 index 0000000..09e580e --- /dev/null +++ b/game_data.py @@ -0,0 +1,83 @@ +"""Load vendored Idle Fantasy recipe data (synced from the game repo).""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +GAME_DATA_ROOT = Path(__file__).parent / "game_data" +RECIPES_DIR = GAME_DATA_ROOT / "recipes" +MANIFEST_PATH = GAME_DATA_ROOT / "manifest.json" + +RECIPE_SKILL_MAP: dict[str, str] = { + "smithing.json": "smithing", + "crafting.json": "crafting", + "cooking.json": "cooking", + "fletching.json": "fletching", + "herblore.json": "herblore", + "construction.json": "construction", +} + + +def manifest() -> dict[str, Any]: + if not MANIFEST_PATH.is_file(): + return {} + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +@lru_cache(maxsize=1) +def _load_all_recipes() -> dict[str, dict[str, dict[str, Any]]]: + """skill_key → activity_key → recipe dict.""" + by_skill: dict[str, dict[str, dict[str, Any]]] = {} + if not RECIPES_DIR.is_dir(): + return by_skill + for filename, skill_key in RECIPE_SKILL_MAP.items(): + path = RECIPES_DIR / filename + if not path.is_file(): + continue + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + by_skill[skill_key] = data + return by_skill + + +def recipes_for_skill(skill_key: str) -> dict[str, dict[str, Any]]: + return dict(_load_all_recipes().get(skill_key, {})) + + +def supported_advisor_skills() -> list[str]: + return sorted(_load_all_recipes().keys()) + + +def xp_per_minute(recipe: dict[str, Any]) -> float: + xp = float(recipe.get("xp_per_item") or 0) + seconds = float(recipe.get("time_per_item") or 60) + if seconds <= 0: + return 0.0 + return xp * 60.0 / seconds + + +def format_display_name(recipe: dict[str, Any], activity_key: str) -> str: + name = recipe.get("display_name") + if name: + return str(name) + return activity_key.replace("_", " ").title() + + +def find_recipe(activity_key: str) -> tuple[str, dict[str, Any]] | None: + """Return (skill_key, recipe) for an activity/output key, if known.""" + for skill_key, recipes in _load_all_recipes().items(): + recipe = recipes.get(activity_key) + if recipe: + return skill_key, recipe + return None + + +def recipe_display_name(activity_key: str) -> str | None: + found = find_recipe(activity_key) + if not found: + return None + _, recipe = found + return format_display_name(recipe, activity_key) diff --git a/game_data/ATTRIBUTION.md b/game_data/ATTRIBUTION.md new file mode 100644 index 0000000..4014d7b --- /dev/null +++ b/game_data/ATTRIBUTION.md @@ -0,0 +1,11 @@ +# Game data attribution + +Recipe JSON files in `recipes/` are synced from the Idle Fantasy open-source game: + +- **Source:** https://github.com/tristinbaker/IdleFantasy +- **Path:** `app/src/main/assets/data/recipes/` +- **License:** GNU General Public License v3.0 + +Refresh with: `python scripts/sync_game_data.py` + +The `manifest.json` file records the upstream git ref used for the last sync. diff --git a/game_data/manifest.json b/game_data/manifest.json new file mode 100644 index 0000000..2fe78ee --- /dev/null +++ b/game_data/manifest.json @@ -0,0 +1,14 @@ +{ + "source_repo": "https://github.com/tristinbaker/IdleFantasy", + "source_ref": "main", + "source_sha": "3fbbf0bfe1e3cc07574e03c29e6950cff75ca270", + "synced_at": "2026-06-22T13:13:53.438764+00:00", + "files": [ + "smithing.json", + "crafting.json", + "cooking.json", + "fletching.json", + "herblore.json", + "construction.json" + ] +} diff --git a/game_data/recipes/construction.json b/game_data/recipes/construction.json new file mode 100644 index 0000000..deb97b1 --- /dev/null +++ b/game_data/recipes/construction.json @@ -0,0 +1,82 @@ +{ + "wooden_rack": { + "display_name": "Wooden Rack", + "level_required": 1, + "materials": { "plank": 2, "iron_nail": 5 }, + "output_quantity": 1, + "xp_per_item": 30.0, + "time_per_item": 60 + }, + "wooden_shelf": { + "display_name": "Wooden Shelf", + "level_required": 10, + "materials": { "plank": 3, "iron_nail": 8 }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "carved_stone": { + "display_name": "Carved Stone", + "level_required": 15, + "materials": { "stone": 1 }, + "output_quantity": 1, + "xp_per_item": 25.0, + "time_per_item": 60 + }, + "oak_table": { + "display_name": "Oak Table", + "level_required": 20, + "materials": { "oak_plank": 4, "iron_nail": 8 }, + "output_quantity": 1, + "xp_per_item": 80.0, + "time_per_item": 60 + }, + "oak_bookshelf": { + "display_name": "Oak Bookshelf", + "level_required": 30, + "materials": { "oak_plank": 5, "steel_nail": 10 }, + "output_quantity": 1, + "xp_per_item": 120.0, + "time_per_item": 60 + }, + "stone_block": { + "display_name": "Stone Block", + "level_required": 40, + "materials": { "carved_stone": 2 }, + "output_quantity": 1, + "xp_per_item": 60.0, + "time_per_item": 60 + }, + "willow_cabinet": { + "display_name": "Willow Cabinet", + "level_required": 40, + "materials": { "willow_plank": 5, "steel_nail": 10 }, + "output_quantity": 1, + "xp_per_item": 165.0, + "time_per_item": 60 + }, + "maple_dresser": { + "display_name": "Maple Dresser", + "level_required": 55, + "materials": { "maple_plank": 6, "steel_nail": 12 }, + "output_quantity": 1, + "xp_per_item": 220.0, + "time_per_item": 60 + }, + "yew_wardrobe": { + "display_name": "Yew Wardrobe", + "level_required": 65, + "materials": { "yew_plank": 6, "mithril_nail": 12 }, + "output_quantity": 1, + "xp_per_item": 285.0, + "time_per_item": 60 + }, + "magic_throne": { + "display_name": "Magic Throne", + "level_required": 75, + "materials": { "magic_plank": 8, "mithril_nail": 15 }, + "output_quantity": 1, + "xp_per_item": 360.0, + "time_per_item": 60 + } +} diff --git a/game_data/recipes/cooking.json b/game_data/recipes/cooking.json new file mode 100644 index 0000000..13561d9 --- /dev/null +++ b/game_data/recipes/cooking.json @@ -0,0 +1,155 @@ +{ + "rat_meat": { + "raw_item": "raw_rat_meat", + "cooked_item": "cooked_rat_meat", + "display_name": "Cooked Rat Meat", + "level_required": 1, + "xp_per_item": 5, + "healing_value": 1, + "time_per_item": 60 + }, + "chicken": { + "raw_item": "raw_chicken", + "cooked_item": "cooked_chicken", + "display_name": "Cooked Chicken", + "level_required": 1, + "xp_per_item": 10, + "healing_value": 3, + "time_per_item": 60 + }, + "mutton": { + "raw_item": "raw_mutton", + "cooked_item": "cooked_mutton", + "display_name": "Cooked Mutton", + "level_required": 1, + "xp_per_item": 15, + "healing_value": 6, + "time_per_item": 60 + }, + "beef": { + "raw_item": "raw_beef", + "cooked_item": "cooked_beef", + "display_name": "Cooked Beef", + "level_required": 5, + "xp_per_item": 25, + "healing_value": 8, + "time_per_item": 60 + }, + "shrimp": { + "raw_item": "raw_shrimp", + "cooked_item": "shrimp", + "display_name": "Shrimp", + "level_required": 1, + "xp_per_item": 30, + "healing_value": 3, + "time_per_item": 60 + }, + "sardine": { + "raw_item": "raw_sardine", + "cooked_item": "sardine", + "display_name": "Sardine", + "level_required": 1, + "xp_per_item": 40, + "healing_value": 3, + "time_per_item": 60 + }, + "herring": { + "raw_item": "raw_herring", + "cooked_item": "herring", + "display_name": "Herring", + "level_required": 5, + "xp_per_item": 50, + "healing_value": 5, + "time_per_item": 60 + }, + "mackerel": { + "raw_item": "raw_mackerel", + "cooked_item": "mackerel", + "display_name": "Mackerel", + "level_required": 10, + "xp_per_item": 60, + "healing_value": 6, + "time_per_item": 60 + }, + "trout": { + "raw_item": "raw_trout", + "cooked_item": "trout", + "display_name": "Trout", + "level_required": 15, + "xp_per_item": 70, + "healing_value": 7, + "time_per_item": 60 + }, + "salmon": { + "raw_item": "raw_salmon", + "cooked_item": "salmon", + "display_name": "Salmon", + "level_required": 25, + "xp_per_item": 90, + "healing_value": 9, + "time_per_item": 60 + }, + "tuna": { + "raw_item": "raw_tuna", + "cooked_item": "tuna", + "display_name": "Tuna", + "level_required": 30, + "xp_per_item": 100, + "healing_value": 10, + "time_per_item": 60 + }, + "lobster": { + "raw_item": "raw_lobster", + "cooked_item": "lobster", + "display_name": "Lobster", + "level_required": 40, + "xp_per_item": 120, + "healing_value": 12, + "time_per_item": 60 + }, + "swordfish": { + "raw_item": "raw_swordfish", + "cooked_item": "swordfish", + "display_name": "Swordfish", + "level_required": 50, + "xp_per_item": 140, + "healing_value": 14, + "time_per_item": 60 + }, + "monkfish": { + "raw_item": "raw_monkfish", + "cooked_item": "monkfish", + "display_name": "Monkfish", + "level_required": 62, + "xp_per_item": 150, + "healing_value": 16, + "time_per_item": 60 + }, + "shark": { + "raw_item": "raw_shark", + "cooked_item": "shark", + "display_name": "Shark", + "level_required": 80, + "xp_per_item": 210, + "healing_value": 20, + "time_per_item": 60 + }, + "sea_turtle": { + "raw_item": "raw_sea_turtle", + "cooked_item": "sea_turtle", + "display_name": "Sea Turtle", + "level_required": 82, + "xp_per_item": 212, + "healing_value": 21, + "time_per_item": 60 + }, + "manta_ray": { + "raw_item": "raw_manta_ray", + "cooked_item": "manta_ray", + "display_name": "Manta Ray", + "level_required": 91, + "xp_per_item": 216, + "healing_value": 22, + "time_per_item": 60 + } +} diff --git a/game_data/recipes/crafting.json b/game_data/recipes/crafting.json new file mode 100644 index 0000000..794995b --- /dev/null +++ b/game_data/recipes/crafting.json @@ -0,0 +1,356 @@ +{ + "silver_ring": { + "type": "jewelry", + "display_name": "Silver Ring", + "level_required": 1, + "materials": { + "silver_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 10, + "time_per_item": 60 + }, + "silver_necklace": { + "type": "jewelry", + "display_name": "Silver Necklace", + "level_required": 5, + "materials": { + "silver_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 20, + "time_per_item": 60 + }, + "gold_ring": { + "type": "jewelry", + "display_name": "Gold Ring", + "level_required": 10, + "materials": { + "gold_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "gold_necklace": { + "type": "jewelry", + "display_name": "Gold Necklace", + "level_required": 15, + "materials": { + "gold_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "platinum_ring": { + "type": "jewelry", + "display_name": "Platinum Ring", + "level_required": 20, + "materials": { + "platinum_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "platinum_necklace": { + "type": "jewelry", + "display_name": "Platinum Necklace", + "level_required": 25, + "materials": { + "platinum_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "silver_sapphire_ring": { + "type": "jewelry", + "display_name": "Silver Sapphire Ring", + "level_required": 30, + "materials": { + "silver_bar": 1, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 60, + "time_per_item": 60 + }, + "silver_sapphire_necklace": { + "type": "jewelry", + "display_name": "Silver Sapphire Necklace", + "level_required": 35, + "materials": { + "silver_bar": 2, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 85, + "time_per_item": 60 + }, + "gold_sapphire_ring": { + "type": "jewelry", + "display_name": "Gold Sapphire Ring", + "level_required": 40, + "materials": { + "gold_bar": 1, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 100, + "time_per_item": 60 + }, + "gold_sapphire_necklace": { + "type": "jewelry", + "display_name": "Gold Sapphire Necklace", + "level_required": 45, + "materials": { + "gold_bar": 2, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 125, + "time_per_item": 60 + }, + "platinum_sapphire_ring": { + "type": "jewelry", + "display_name": "Platinum Sapphire Ring", + "level_required": 50, + "materials": { + "platinum_bar": 1, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 150, + "time_per_item": 60 + }, + "platinum_sapphire_necklace": { + "type": "jewelry", + "display_name": "Platinum Sapphire Necklace", + "level_required": 55, + "materials": { + "platinum_bar": 2, + "sapphire": 1 + }, + "output_quantity": 1, + "xp_per_item": 180, + "time_per_item": 60 + }, + "silver_emerald_ring": { + "type": "jewelry", + "display_name": "Silver Emerald Ring", + "level_required": 32, + "materials": { + "silver_bar": 1, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 65, + "time_per_item": 60 + }, + "silver_emerald_necklace": { + "type": "jewelry", + "display_name": "Silver Emerald Necklace", + "level_required": 37, + "materials": { + "silver_bar": 2, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 90, + "time_per_item": 60 + }, + "gold_emerald_ring": { + "type": "jewelry", + "display_name": "Gold Emerald Ring", + "level_required": 42, + "materials": { + "gold_bar": 1, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 110, + "time_per_item": 60 + }, + "gold_emerald_necklace": { + "type": "jewelry", + "display_name": "Gold Emerald Necklace", + "level_required": 47, + "materials": { + "gold_bar": 2, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 140, + "time_per_item": 60 + }, + "platinum_emerald_ring": { + "type": "jewelry", + "display_name": "Platinum Emerald Ring", + "level_required": 52, + "materials": { + "platinum_bar": 1, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 165, + "time_per_item": 60 + }, + "platinum_emerald_necklace": { + "type": "jewelry", + "display_name": "Platinum Emerald Necklace", + "level_required": 57, + "materials": { + "platinum_bar": 2, + "emerald": 1 + }, + "output_quantity": 1, + "xp_per_item": 200, + "time_per_item": 60 + }, + "silver_ruby_ring": { + "type": "jewelry", + "display_name": "Silver Ruby Ring", + "level_required": 44, + "materials": { + "silver_bar": 1, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 130, + "time_per_item": 60 + }, + "silver_ruby_necklace": { + "type": "jewelry", + "display_name": "Silver Ruby Necklace", + "level_required": 49, + "materials": { + "silver_bar": 2, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 160, + "time_per_item": 60 + }, + "silver_diamond_ring": { + "type": "jewelry", + "display_name": "Silver Diamond Ring", + "level_required": 56, + "materials": { + "silver_bar": 1, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 195, + "time_per_item": 60 + }, + "silver_diamond_necklace": { + "type": "jewelry", + "display_name": "Silver Diamond Necklace", + "level_required": 61, + "materials": { + "silver_bar": 2, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 240, + "time_per_item": 60 + }, + "gold_ruby_ring": { + "type": "jewelry", + "display_name": "Gold Ruby Ring", + "level_required": 54, + "materials": { + "gold_bar": 1, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 170, + "time_per_item": 60 + }, + "gold_ruby_necklace": { + "type": "jewelry", + "display_name": "Gold Ruby Necklace", + "level_required": 59, + "materials": { + "gold_bar": 2, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 210, + "time_per_item": 60 + }, + "platinum_ruby_ring": { + "type": "jewelry", + "display_name": "Platinum Ruby Ring", + "level_required": 64, + "materials": { + "platinum_bar": 1, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 240, + "time_per_item": 60 + }, + "platinum_ruby_necklace": { + "type": "jewelry", + "display_name": "Platinum Ruby Necklace", + "level_required": 69, + "materials": { + "platinum_bar": 2, + "ruby": 1 + }, + "output_quantity": 1, + "xp_per_item": 280, + "time_per_item": 60 + }, + "gold_diamond_ring": { + "type": "jewelry", + "display_name": "Gold Diamond Ring", + "level_required": 66, + "materials": { + "gold_bar": 1, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 250, + "time_per_item": 60 + }, + "gold_diamond_necklace": { + "type": "jewelry", + "display_name": "Gold Diamond Necklace", + "level_required": 71, + "materials": { + "gold_bar": 2, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 300, + "time_per_item": 60 + }, + "platinum_diamond_ring": { + "type": "jewelry", + "display_name": "Platinum Diamond Ring", + "level_required": 76, + "materials": { + "platinum_bar": 1, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 330, + "time_per_item": 60 + }, + "platinum_diamond_necklace": { + "type": "jewelry", + "display_name": "Platinum Diamond Necklace", + "level_required": 81, + "materials": { + "platinum_bar": 2, + "diamond": 1 + }, + "output_quantity": 1, + "xp_per_item": 400, + "time_per_item": 60 + } +} diff --git a/game_data/recipes/fletching.json b/game_data/recipes/fletching.json new file mode 100644 index 0000000..51db46b --- /dev/null +++ b/game_data/recipes/fletching.json @@ -0,0 +1,458 @@ +{ + "arrow_shaft": { + "item_name": "arrow_shaft", + "display_name": "Arrow Shaft", + "type": "component", + "level_required": 1, + "xp_per_item": 5, + "materials": { + "log": 1 + }, + "output_quantity": 15, + "time_per_batch": 1 + }, + "bronze_arrow": { + "item_name": "bronze_arrow", + "display_name": "Bronze Arrow", + "type": "ammunition", + "level_required": 1, + "xp_per_item": 2, + "materials": { + "arrow_shaft": 15, + "bronze_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 7, + "requirements": {} + }, + "iron_arrow": { + "item_name": "iron_arrow", + "display_name": "Iron Arrow", + "type": "ammunition", + "level_required": 15, + "xp_per_item": 4, + "materials": { + "arrow_shaft": 15, + "iron_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 10, + "requirements": {} + }, + "steel_arrow": { + "item_name": "steel_arrow", + "display_name": "Steel Arrow", + "type": "ammunition", + "level_required": 30, + "xp_per_item": 6, + "materials": { + "arrow_shaft": 15, + "steel_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 16, + "requirements": {} + }, + "mithril_arrow": { + "item_name": "mithril_arrow", + "display_name": "Mithril Arrow", + "type": "ammunition", + "level_required": 45, + "xp_per_item": 8, + "materials": { + "arrow_shaft": 15, + "mithril_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 22, + "requirements": {} + }, + "adamantite_arrow": { + "item_name": "adamantite_arrow", + "display_name": "Adamantite Arrow", + "type": "ammunition", + "level_required": 60, + "xp_per_item": 10, + "materials": { + "arrow_shaft": 15, + "adamantite_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 31, + "requirements": {} + }, + "runite_arrow": { + "item_name": "runite_arrow", + "display_name": "Runite Arrow", + "type": "ammunition", + "level_required": 75, + "xp_per_item": 12, + "materials": { + "arrow_shaft": 15, + "runite_arrow_tip": 15 + }, + "output_quantity": 15, + "time_per_batch": 1, + "damage": 49, + "requirements": {} + }, + "shortbow": { + "item_name": "shortbow", + "display_name": "Shortbow", + "type": "weapon", + "level_required": 5, + "xp_per_item": 10, + "materials": { + "log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 8, + "strength_bonus": 0, + "requirements": {} + }, + "oak_shortbow": { + "item_name": "oak_shortbow", + "display_name": "Oak Shortbow", + "type": "weapon", + "level_required": 20, + "xp_per_item": 17, + "materials": { + "oak_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 14, + "strength_bonus": 0, + "requirements": { + "ranged": 5 + } + }, + "willow_shortbow": { + "item_name": "willow_shortbow", + "display_name": "Willow Shortbow", + "type": "weapon", + "level_required": 35, + "xp_per_item": 34, + "materials": { + "willow_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 20, + "strength_bonus": 0, + "requirements": { + "ranged": 20 + } + }, + "maple_shortbow": { + "item_name": "maple_shortbow", + "display_name": "Maple Shortbow", + "type": "weapon", + "level_required": 50, + "xp_per_item": 50, + "materials": { + "maple_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 29, + "strength_bonus": 0, + "requirements": { + "ranged": 30 + } + }, + "yew_shortbow": { + "item_name": "yew_shortbow", + "display_name": "Yew Shortbow", + "type": "weapon", + "level_required": 65, + "xp_per_item": 67, + "materials": { + "yew_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 47, + "strength_bonus": 0, + "requirements": { + "ranged": 40 + } + }, + "magic_shortbow": { + "item_name": "magic_shortbow", + "display_name": "Magic Shortbow", + "type": "weapon", + "level_required": 80, + "xp_per_item": 84, + "materials": { + "magic_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 69, + "strength_bonus": 0, + "requirements": { + "ranged": 50 + } + }, + "yew_longbow": { + "item_name": "yew_longbow", + "display_name": "Yew Longbow", + "type": "weapon", + "level_required": 70, + "xp_per_item": 75, + "materials": { + "yew_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 58, + "strength_bonus": 0, + "requirements": { + "ranged": 50 + } + }, + "magic_longbow": { + "item_name": "magic_longbow", + "display_name": "Magic Longbow", + "type": "weapon", + "level_required": 85, + "xp_per_item": 97, + "materials": { + "magic_log": 1 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "ranged", + "attack_bonus": 80, + "strength_bonus": 0, + "requirements": { + "ranged": 60 + } + }, + "staff_of_air": { + "item_name": "staff_of_air", + "display_name": "Staff of Air", + "type": "weapon", + "level_required": 1, + "xp_per_item": 50, + "materials": { + "log": 1, + "air_rune": 1000 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 5, + "strength_bonus": 6, + "requirements": { + "magic": 1 + } + }, + "staff_of_water": { + "item_name": "staff_of_water", + "display_name": "Staff of Water", + "type": "weapon", + "level_required": 5, + "xp_per_item": 60, + "materials": { + "oak_log": 1, + "water_rune": 1000 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 7, + "strength_bonus": 8, + "requirements": { + "magic": 5 + } + }, + "staff_of_earth": { + "item_name": "staff_of_earth", + "display_name": "Staff of Earth", + "type": "weapon", + "level_required": 10, + "xp_per_item": 75, + "materials": { + "willow_log": 1, + "earth_rune": 1500 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 10, + "strength_bonus": 11, + "requirements": { + "magic": 10 + } + }, + "staff_of_fire": { + "item_name": "staff_of_fire", + "display_name": "Staff of Fire", + "type": "weapon", + "level_required": 15, + "xp_per_item": 90, + "materials": { + "maple_log": 1, + "fire_rune": 1500 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 14, + "strength_bonus": 15, + "requirements": { + "magic": 15 + } + }, + "staff_of_mind": { + "item_name": "staff_of_mind", + "display_name": "Staff of Mind", + "type": "weapon", + "level_required": 25, + "xp_per_item": 110, + "materials": { + "yew_log": 1, + "mind_rune": 1500 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 19, + "strength_bonus": 20, + "requirements": { + "magic": 25 + } + }, + "staff_of_chaos": { + "item_name": "staff_of_chaos", + "display_name": "Staff of Chaos", + "type": "weapon", + "level_required": 35, + "xp_per_item": 135, + "materials": { + "magic_log": 1, + "chaos_rune": 2000 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 25, + "strength_bonus": 26, + "requirements": { + "magic": 35 + } + }, + "staff_of_death": { + "item_name": "staff_of_death", + "display_name": "Staff of Death", + "type": "weapon", + "level_required": 50, + "xp_per_item": 170, + "materials": { + "redwood_log": 1, + "death_rune": 2000 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 32, + "strength_bonus": 33, + "requirements": { + "magic": 50 + } + }, + "staff_of_blood": { + "item_name": "staff_of_blood", + "display_name": "Staff of Blood", + "type": "weapon", + "level_required": 65, + "xp_per_item": 210, + "materials": { + "redwood_log": 1, + "blood_rune": 2000 + }, + "output_quantity": 1, + "time_per_batch": 1, + "combat_style": "magic", + "attack_bonus": 40, + "strength_bonus": 41, + "requirements": { + "magic": 65 + } + }, + "plank": { + "item_name": "plank", + "display_name": "Plank", + "type": "component", + "level_required": 1, + "xp_per_item": 20, + "materials": { "log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + }, + "oak_plank": { + "item_name": "oak_plank", + "display_name": "Oak Plank", + "type": "component", + "level_required": 15, + "xp_per_item": 40, + "materials": { "oak_log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + }, + "willow_plank": { + "item_name": "willow_plank", + "display_name": "Willow Plank", + "type": "component", + "level_required": 30, + "xp_per_item": 60, + "materials": { "willow_log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + }, + "maple_plank": { + "item_name": "maple_plank", + "display_name": "Maple Plank", + "type": "component", + "level_required": 45, + "xp_per_item": 90, + "materials": { "maple_log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + }, + "yew_plank": { + "item_name": "yew_plank", + "display_name": "Yew Plank", + "type": "component", + "level_required": 60, + "xp_per_item": 130, + "materials": { "yew_log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + }, + "magic_plank": { + "item_name": "magic_plank", + "display_name": "Magic Plank", + "type": "component", + "level_required": 75, + "xp_per_item": 175, + "materials": { "magic_log": 1 }, + "output_quantity": 1, + "time_per_batch": 1 + } +} diff --git a/game_data/recipes/herblore.json b/game_data/recipes/herblore.json new file mode 100644 index 0000000..2f275e4 --- /dev/null +++ b/game_data/recipes/herblore.json @@ -0,0 +1,146 @@ +{ + "attack_brew": { + "display_name": "Attack Brew", + "level_required": 1, + "materials": { "potato": 2 }, + "output_quantity": 1, + "xp_per_item": 10.0, + "time_per_item": 120, + "effects": { "attack": 3 } + }, + "defense_brew": { + "display_name": "Defense Brew", + "level_required": 5, + "materials": { "cabbage": 2 }, + "output_quantity": 1, + "xp_per_item": 15.0, + "time_per_item": 120, + "effects": { "defense": 3 } + }, + "attack_potion": { + "display_name": "Attack Potion", + "level_required": 8, + "materials": { "potato": 1, "imp_hide": 1 }, + "output_quantity": 1, + "xp_per_item": 25.0, + "time_per_item": 120, + "effects": { "attack": 5 } + }, + "defense_potion": { + "display_name": "Defense Potion", + "level_required": 14, + "materials": { "cabbage": 1, "goblin_mail": 1 }, + "output_quantity": 1, + "xp_per_item": 38.0, + "time_per_item": 120, + "effects": { "defense": 5 } + }, + "onion_brew": { + "display_name": "Onion Brew", + "level_required": 10, + "materials": { "onion": 2 }, + "output_quantity": 1, + "xp_per_item": 20.0, + "time_per_item": 120, + "effects": { "attack": 2, "strength": 1 } + }, + "carrot_brew": { + "display_name": "Carrot Brew", + "level_required": 17, + "materials": { "carrot": 2 }, + "output_quantity": 1, + "xp_per_item": 30.0, + "time_per_item": 120, + "effects": { "ranged": 2 } + }, + "strength_brew": { + "display_name": "Strength Brew", + "level_required": 20, + "materials": { "corn": 2 }, + "output_quantity": 1, + "xp_per_item": 30.0, + "time_per_item": 120, + "effects": { "strength": 3 } + }, + "strength_potion": { + "display_name": "Strength Potion", + "level_required": 26, + "materials": { "corn": 1, "spider_silk": 1 }, + "output_quantity": 1, + "xp_per_item": 55.0, + "time_per_item": 120, + "effects": { "strength": 5 } + }, + "ranging_potion": { + "display_name": "Ranging Potion", + "level_required": 30, + "materials": { "corn": 1, "spider_fang": 1 }, + "output_quantity": 1, + "xp_per_item": 60.0, + "time_per_item": 120, + "effects": { "ranged": 5 } + }, + "magic_potion": { + "display_name": "Magic Potion", + "level_required": 38, + "materials": { "magic_herb": 1, "magic_bean": 1 }, + "output_quantity": 1, + "xp_per_item": 75.0, + "time_per_item": 120, + "effects": { "magic": 5 } + }, + "super_strength_potion": { + "display_name": "Super Strength Potion", + "level_required": 48, + "materials": { "dragon_fruit": 1, "rotten_flesh": 1 }, + "output_quantity": 1, + "xp_per_item": 105.0, + "time_per_item": 120, + "effects": { "strength": 8 } + }, + "super_attack_potion": { + "display_name": "Super Attack Potion", + "level_required": 56, + "materials": { "golden_wheat": 2 }, + "output_quantity": 1, + "xp_per_item": 125.0, + "time_per_item": 120, + "effects": { "attack": 8 } + }, + "super_defense_potion": { + "display_name": "Super Defense Potion", + "level_required": 66, + "materials": { "spirit_herb": 1, "troll_bone": 1 }, + "output_quantity": 1, + "xp_per_item": 145.0, + "time_per_item": 120, + "effects": { "defense": 8 } + }, + "super_ranging_potion": { + "display_name": "Super Ranging Potion", + "level_required": 76, + "materials": { "celestial_bloom": 1, "hellhound_fang": 1 }, + "output_quantity": 1, + "xp_per_item": 180.0, + "time_per_item": 120, + "effects": { "ranged": 10 } + }, + "super_magic_potion": { + "display_name": "Super Magic Potion", + "level_required": 88, + "materials": { "starfruit": 1, "dragon_scale": 1 }, + "output_quantity": 1, + "xp_per_item": 210.0, + "time_per_item": 120, + "effects": { "magic": 10 } + }, + "overload_potion": { + "display_name": "Overload Potion", + "level_required": 96, + "materials": { "void_lotus": 1, "demon_horn": 1 }, + "output_quantity": 1, + "xp_per_item": 350.0, + "time_per_item": 120, + "effects": { "attack": 10, "strength": 10, "defense": 10, "ranged": 10, "magic": 10 } + } +} diff --git a/game_data/recipes/smithing.json b/game_data/recipes/smithing.json new file mode 100644 index 0000000..a3ffd47 --- /dev/null +++ b/game_data/recipes/smithing.json @@ -0,0 +1,1507 @@ +{ + "bronze_bar": { + "type": "bar", + "display_name": "Bronze Bar", + "level_required": 1, + "materials": { + "copper_ore": 1, + "tin_ore": 1 + }, + "output_quantity": 1, + "xp_per_item": 6.2, + "time_per_item": 60 + }, + "iron_bar": { + "type": "bar", + "display_name": "Iron Bar", + "level_required": 15, + "materials": { + "iron_ore": 1 + }, + "output_quantity": 1, + "xp_per_item": 12.5, + "time_per_item": 60 + }, + "silver_bar": { + "type": "bar", + "display_name": "Silver Bar", + "level_required": 20, + "materials": { + "silver_ore": 1 + }, + "output_quantity": 1, + "xp_per_item": 13.5, + "time_per_item": 60 + }, + "steel_bar": { + "type": "bar", + "display_name": "Steel Bar", + "level_required": 30, + "materials": { + "iron_ore": 1, + "coal": 2 + }, + "output_quantity": 1, + "xp_per_item": 17.5, + "time_per_item": 60 + }, + "gold_bar": { + "type": "bar", + "display_name": "Gold Bar", + "level_required": 40, + "materials": { + "gold_ore": 1 + }, + "output_quantity": 1, + "xp_per_item": 22.5, + "time_per_item": 60 + }, + "mithril_bar": { + "type": "bar", + "display_name": "Mithril Bar", + "level_required": 50, + "materials": { + "mithril_ore": 1, + "coal": 4 + }, + "output_quantity": 1, + "xp_per_item": 30.0, + "time_per_item": 60 + }, + "adamantite_bar": { + "type": "bar", + "display_name": "Adamantite Bar", + "level_required": 70, + "materials": { + "adamantite_ore": 1, + "coal": 6 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "platinum_bar": { + "type": "bar", + "display_name": "Platinum Bar", + "level_required": 80, + "materials": { + "platinum_ore": 1 + }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "runite_bar": { + "type": "bar", + "display_name": "Runite Bar", + "level_required": 85, + "materials": { + "runite_ore": 1, + "coal": 8 + }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "bronze_arrow_tip": { + "type": "component", + "display_name": "Bronze Arrow Tips", + "level_required": 1, + "materials": { + "bronze_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 6, + "time_per_item": 60 + }, + "iron_arrow_tip": { + "type": "component", + "display_name": "Iron Arrow Tips", + "level_required": 20, + "materials": { + "iron_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 13, + "time_per_item": 60 + }, + "steel_arrow_tip": { + "type": "component", + "display_name": "Steel Arrow Tips", + "level_required": 35, + "materials": { + "steel_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 20, + "time_per_item": 60 + }, + "mithril_arrow_tip": { + "type": "component", + "display_name": "Mithril Arrow Tips", + "level_required": 55, + "materials": { + "mithril_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 28.0, + "time_per_item": 60 + }, + "adamantite_arrow_tip": { + "type": "component", + "display_name": "Adamantite Arrow Tips", + "level_required": 75, + "materials": { + "adamantite_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 36.0, + "time_per_item": 60 + }, + "runite_arrow_tip": { + "type": "component", + "display_name": "Runite Arrow Tips", + "level_required": 90, + "materials": { + "runite_bar": 1 + }, + "output_quantity": 15, + "xp_per_item": 45.0, + "time_per_item": 60 + }, + "bronze_dagger": { + "type": "equipment", + "display_name": "Bronze Dagger", + "level_required": 1, + "materials": { + "bronze_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 12.5, + "time_per_item": 60 + }, + "iron_dagger": { + "type": "equipment", + "display_name": "Iron Dagger", + "level_required": 15, + "materials": { + "iron_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "steel_dagger": { + "type": "equipment", + "display_name": "Steel Dagger", + "level_required": 30, + "materials": { + "steel_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "mithril_dagger": { + "type": "equipment", + "display_name": "Mithril Dagger", + "level_required": 50, + "materials": { + "mithril_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "adamantite_dagger": { + "type": "equipment", + "display_name": "Adamantite Dagger", + "level_required": 70, + "materials": { + "adamantite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 62.5, + "time_per_item": 60 + }, + "runite_dagger": { + "type": "equipment", + "display_name": "Runite Dagger", + "level_required": 85, + "materials": { + "runite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 75.0, + "time_per_item": 60 + }, + "bronze_sword": { + "type": "equipment", + "display_name": "Bronze Sword", + "level_required": 4, + "materials": { + "bronze_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 12.5, + "time_per_item": 60 + }, + "iron_sword": { + "type": "equipment", + "display_name": "Iron Sword", + "level_required": 19, + "materials": { + "iron_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "steel_sword": { + "type": "equipment", + "display_name": "Steel Sword", + "level_required": 34, + "materials": { + "steel_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "mithril_sword": { + "type": "equipment", + "display_name": "Mithril Sword", + "level_required": 54, + "materials": { + "mithril_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "adamantite_sword": { + "type": "equipment", + "display_name": "Adamantite Sword", + "level_required": 74, + "materials": { + "adamantite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 62.5, + "time_per_item": 60 + }, + "runite_sword": { + "type": "equipment", + "display_name": "Runite Sword", + "level_required": 89, + "materials": { + "runite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 75.0, + "time_per_item": 60 + }, + "bronze_scimitar": { + "type": "equipment", + "display_name": "Bronze Scimitar", + "level_required": 5, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "iron_scimitar": { + "type": "equipment", + "display_name": "Iron Scimitar", + "level_required": 20, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "steel_scimitar": { + "type": "equipment", + "display_name": "Steel Scimitar", + "level_required": 35, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "mithril_scimitar": { + "type": "equipment", + "display_name": "Mithril Scimitar", + "level_required": 55, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 115.0, + "time_per_item": 60 + }, + "adamantite_scimitar": { + "type": "equipment", + "display_name": "Adamantite Scimitar", + "level_required": 75, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 143.8, + "time_per_item": 60 + }, + "runite_scimitar": { + "type": "equipment", + "display_name": "Runite Scimitar", + "level_required": 90, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 172.5, + "time_per_item": 60 + }, + "bronze_longsword": { + "type": "equipment", + "display_name": "Bronze Longsword", + "level_required": 6, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "iron_longsword": { + "type": "equipment", + "display_name": "Iron Longsword", + "level_required": 21, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "steel_longsword": { + "type": "equipment", + "display_name": "Steel Longsword", + "level_required": 36, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "mithril_longsword": { + "type": "equipment", + "display_name": "Mithril Longsword", + "level_required": 56, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 115.0, + "time_per_item": 60 + }, + "adamantite_longsword": { + "type": "equipment", + "display_name": "Adamantite Longsword", + "level_required": 76, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 143.8, + "time_per_item": 60 + }, + "runite_longsword": { + "type": "equipment", + "display_name": "Runite Longsword", + "level_required": 91, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 172.5, + "time_per_item": 60 + }, + "bronze_2h_sword": { + "type": "equipment", + "display_name": "Bronze 2H Sword", + "level_required": 14, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_2h_sword": { + "type": "equipment", + "display_name": "Iron 2H Sword", + "level_required": 29, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_2h_sword": { + "type": "equipment", + "display_name": "Steel 2H Sword", + "level_required": 44, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_2h_sword": { + "type": "equipment", + "display_name": "Mithril 2H Sword", + "level_required": 64, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_2h_sword": { + "type": "equipment", + "display_name": "Adamantite 2H Sword", + "level_required": 84, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_2h_sword": { + "type": "equipment", + "display_name": "Runite 2H Sword", + "level_required": 99, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_warhammer": { + "type": "equipment", + "display_name": "Bronze Warhammer", + "level_required": 9, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_warhammer": { + "type": "equipment", + "display_name": "Iron Warhammer", + "level_required": 24, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_warhammer": { + "type": "equipment", + "display_name": "Steel Warhammer", + "level_required": 39, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_warhammer": { + "type": "equipment", + "display_name": "Mithril Warhammer", + "level_required": 59, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_warhammer": { + "type": "equipment", + "display_name": "Adamantite Warhammer", + "level_required": 79, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_warhammer": { + "type": "equipment", + "display_name": "Runite Warhammer", + "level_required": 94, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_battleaxe": { + "type": "equipment", + "display_name": "Bronze Battleaxe", + "level_required": 10, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_battleaxe": { + "type": "equipment", + "display_name": "Iron Battleaxe", + "level_required": 25, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_battleaxe": { + "type": "equipment", + "display_name": "Steel Battleaxe", + "level_required": 40, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_battleaxe": { + "type": "equipment", + "display_name": "Mithril Battleaxe", + "level_required": 60, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_battleaxe": { + "type": "equipment", + "display_name": "Adamantite Battleaxe", + "level_required": 80, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_battleaxe": { + "type": "equipment", + "display_name": "Runite Battleaxe", + "level_required": 95, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_med_helmet": { + "type": "equipment", + "display_name": "Bronze Med Helmet", + "level_required": 3, + "materials": { + "bronze_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 12.5, + "time_per_item": 60 + }, + "iron_med_helmet": { + "type": "equipment", + "display_name": "Iron Med Helmet", + "level_required": 18, + "materials": { + "iron_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "steel_med_helmet": { + "type": "equipment", + "display_name": "Steel Med Helmet", + "level_required": 33, + "materials": { + "steel_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "mithril_med_helmet": { + "type": "equipment", + "display_name": "Mithril Med Helmet", + "level_required": 53, + "materials": { + "mithril_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "adamantite_med_helmet": { + "type": "equipment", + "display_name": "Adamantite Med Helmet", + "level_required": 73, + "materials": { + "adamantite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 62.5, + "time_per_item": 60 + }, + "runite_med_helmet": { + "type": "equipment", + "display_name": "Runite Med Helmet", + "level_required": 88, + "materials": { + "runite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 75.0, + "time_per_item": 60 + }, + "bronze_boots": { + "type": "equipment", + "display_name": "Bronze Boots", + "level_required": 4, + "materials": { + "bronze_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 12.5, + "time_per_item": 60 + }, + "iron_boots": { + "type": "equipment", + "display_name": "Iron Boots", + "level_required": 19, + "materials": { + "iron_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "steel_boots": { + "type": "equipment", + "display_name": "Steel Boots", + "level_required": 34, + "materials": { + "steel_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "mithril_boots": { + "type": "equipment", + "display_name": "Mithril Boots", + "level_required": 54, + "materials": { + "mithril_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 50.0, + "time_per_item": 60 + }, + "adamantite_boots": { + "type": "equipment", + "display_name": "Adamantite Boots", + "level_required": 74, + "materials": { + "adamantite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 62.5, + "time_per_item": 60 + }, + "runite_boots": { + "type": "equipment", + "display_name": "Runite Boots", + "level_required": 89, + "materials": { + "runite_bar": 1 + }, + "output_quantity": 1, + "xp_per_item": 75.0, + "time_per_item": 60 + }, + "bronze_full_helmet": { + "type": "equipment", + "display_name": "Bronze Full Helmet", + "level_required": 7, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "iron_full_helmet": { + "type": "equipment", + "display_name": "Iron Full Helmet", + "level_required": 22, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "steel_full_helmet": { + "type": "equipment", + "display_name": "Steel Full Helmet", + "level_required": 37, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "mithril_full_helmet": { + "type": "equipment", + "display_name": "Mithril Full Helmet", + "level_required": 57, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 115.0, + "time_per_item": 60 + }, + "adamantite_full_helmet": { + "type": "equipment", + "display_name": "Adamantite Full Helmet", + "level_required": 77, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 143.8, + "time_per_item": 60 + }, + "runite_full_helmet": { + "type": "equipment", + "display_name": "Runite Full Helmet", + "level_required": 92, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 172.5, + "time_per_item": 60 + }, + "bronze_chainbody": { + "type": "equipment", + "display_name": "Bronze Chainbody", + "level_required": 11, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_chainbody": { + "type": "equipment", + "display_name": "Iron Chainbody", + "level_required": 26, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_chainbody": { + "type": "equipment", + "display_name": "Steel Chainbody", + "level_required": 41, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_chainbody": { + "type": "equipment", + "display_name": "Mithril Chainbody", + "level_required": 61, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_chainbody": { + "type": "equipment", + "display_name": "Adamantite Chainbody", + "level_required": 81, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_chainbody": { + "type": "equipment", + "display_name": "Runite Chainbody", + "level_required": 96, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_platelegs": { + "type": "equipment", + "display_name": "Bronze Platelegs", + "level_required": 16, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_platelegs": { + "type": "equipment", + "display_name": "Iron Platelegs", + "level_required": 31, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_platelegs": { + "type": "equipment", + "display_name": "Steel Platelegs", + "level_required": 46, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_platelegs": { + "type": "equipment", + "display_name": "Mithril Platelegs", + "level_required": 66, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_platelegs": { + "type": "equipment", + "display_name": "Adamantite Platelegs", + "level_required": 86, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_platelegs": { + "type": "equipment", + "display_name": "Runite Platelegs", + "level_required": 99, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_plateskirt": { + "type": "equipment", + "display_name": "Bronze Plateskirt", + "level_required": 16, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_plateskirt": { + "type": "equipment", + "display_name": "Iron Plateskirt", + "level_required": 31, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_plateskirt": { + "type": "equipment", + "display_name": "Steel Plateskirt", + "level_required": 46, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_plateskirt": { + "type": "equipment", + "display_name": "Mithril Plateskirt", + "level_required": 66, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_plateskirt": { + "type": "equipment", + "display_name": "Adamantite Plateskirt", + "level_required": 86, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_plateskirt": { + "type": "equipment", + "display_name": "Runite Plateskirt", + "level_required": 99, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_platebody": { + "type": "equipment", + "display_name": "Bronze Platebody", + "level_required": 18, + "materials": { + "bronze_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 62.5, + "time_per_item": 60 + }, + "iron_platebody": { + "type": "equipment", + "display_name": "Iron Platebody", + "level_required": 33, + "materials": { + "iron_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 125, + "time_per_item": 60 + }, + "steel_platebody": { + "type": "equipment", + "display_name": "Steel Platebody", + "level_required": 48, + "materials": { + "steel_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 187.5, + "time_per_item": 60 + }, + "mithril_platebody": { + "type": "equipment", + "display_name": "Mithril Platebody", + "level_required": 68, + "materials": { + "mithril_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 462.5, + "time_per_item": 60 + }, + "adamantite_platebody": { + "type": "equipment", + "display_name": "Adamantite Platebody", + "level_required": 88, + "materials": { + "adamantite_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 578.1, + "time_per_item": 60 + }, + "runite_platebody": { + "type": "equipment", + "display_name": "Runite Platebody", + "level_required": 99, + "materials": { + "runite_bar": 5 + }, + "output_quantity": 1, + "xp_per_item": 693.8, + "time_per_item": 60 + }, + "bronze_square_shield": { + "type": "equipment", + "display_name": "Bronze Square Shield", + "level_required": 8, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 25, + "time_per_item": 60 + }, + "iron_square_shield": { + "type": "equipment", + "display_name": "Iron Square Shield", + "level_required": 23, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "steel_square_shield": { + "type": "equipment", + "display_name": "Steel Square Shield", + "level_required": 38, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "mithril_square_shield": { + "type": "equipment", + "display_name": "Mithril Square Shield", + "level_required": 58, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 115.0, + "time_per_item": 60 + }, + "adamantite_square_shield": { + "type": "equipment", + "display_name": "Adamantite Square Shield", + "level_required": 78, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 143.8, + "time_per_item": 60 + }, + "runite_square_shield": { + "type": "equipment", + "display_name": "Runite Square Shield", + "level_required": 93, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 172.5, + "time_per_item": 60 + }, + "bronze_kiteshield": { + "type": "equipment", + "display_name": "Bronze Kiteshield", + "level_required": 12, + "materials": { + "bronze_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 37.5, + "time_per_item": 60 + }, + "iron_kiteshield": { + "type": "equipment", + "display_name": "Iron Kiteshield", + "level_required": 27, + "materials": { + "iron_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 75, + "time_per_item": 60 + }, + "steel_kiteshield": { + "type": "equipment", + "display_name": "Steel Kiteshield", + "level_required": 42, + "materials": { + "steel_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 112.5, + "time_per_item": 60 + }, + "mithril_kiteshield": { + "type": "equipment", + "display_name": "Mithril Kiteshield", + "level_required": 62, + "materials": { + "mithril_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 232.5, + "time_per_item": 60 + }, + "adamantite_kiteshield": { + "type": "equipment", + "display_name": "Adamantite Kiteshield", + "level_required": 82, + "materials": { + "adamantite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 290.6, + "time_per_item": 60 + }, + "runite_kiteshield": { + "type": "equipment", + "display_name": "Runite Kiteshield", + "level_required": 97, + "materials": { + "runite_bar": 3 + }, + "output_quantity": 1, + "xp_per_item": 348.8, + "time_per_item": 60 + }, + "bronze_pickaxe": { + "type": "tool", + "display_name": "Bronze Pickaxe", + "level_required": 1, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 10, + "time_per_item": 60 + }, + "iron_pickaxe": { + "type": "tool", + "display_name": "Iron Pickaxe", + "level_required": 15, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 20, + "time_per_item": 60 + }, + "steel_pickaxe": { + "type": "tool", + "display_name": "Steel Pickaxe", + "level_required": 30, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 30, + "time_per_item": 60 + }, + "mithril_pickaxe": { + "type": "tool", + "display_name": "Mithril Pickaxe", + "level_required": 55, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "adamantite_pickaxe": { + "type": "tool", + "display_name": "Adamantite Pickaxe", + "level_required": 70, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "runite_pickaxe": { + "type": "tool", + "display_name": "Runite Pickaxe", + "level_required": 85, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 60, + "time_per_item": 60 + }, + "bronze_axe": { + "type": "tool", + "display_name": "Bronze Axe", + "level_required": 1, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 10, + "time_per_item": 60 + }, + "iron_axe": { + "type": "tool", + "display_name": "Iron Axe", + "level_required": 15, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 20, + "time_per_item": 60 + }, + "steel_axe": { + "type": "tool", + "display_name": "Steel Axe", + "level_required": 30, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 30, + "time_per_item": 60 + }, + "mithril_axe": { + "type": "tool", + "display_name": "Mithril Axe", + "level_required": 55, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "adamantite_axe": { + "type": "tool", + "display_name": "Adamantite Axe", + "level_required": 70, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "runite_axe": { + "type": "tool", + "display_name": "Runite Axe", + "level_required": 85, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 60, + "time_per_item": 60 + }, + "bronze_fishing_rod": { + "type": "tool", + "display_name": "Bronze Fishing Rod", + "level_required": 1, + "materials": { + "bronze_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 10, + "time_per_item": 60 + }, + "iron_fishing_rod": { + "type": "tool", + "display_name": "Iron Fishing Rod", + "level_required": 15, + "materials": { + "iron_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 20, + "time_per_item": 60 + }, + "steel_fishing_rod": { + "type": "tool", + "display_name": "Steel Fishing Rod", + "level_required": 30, + "materials": { + "steel_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 30, + "time_per_item": 60 + }, + "mithril_fishing_rod": { + "type": "tool", + "display_name": "Mithril Fishing Rod", + "level_required": 55, + "materials": { + "mithril_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "adamantite_fishing_rod": { + "type": "tool", + "display_name": "Adamantite Fishing Rod", + "level_required": 70, + "materials": { + "adamantite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "runite_fishing_rod": { + "type": "tool", + "display_name": "Runite Fishing Rod", + "level_required": 85, + "materials": { + "runite_bar": 2 + }, + "output_quantity": 1, + "xp_per_item": 60, + "time_per_item": 60 + }, + "bronze_hoe": { + "type": "tool", + "display_name": "Bronze Hoe", + "level_required": 1, + "materials": { "bronze_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 10, + "time_per_item": 60 + }, + "iron_hoe": { + "type": "tool", + "display_name": "Iron Hoe", + "level_required": 15, + "materials": { "iron_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 20, + "time_per_item": 60 + }, + "steel_hoe": { + "type": "tool", + "display_name": "Steel Hoe", + "level_required": 30, + "materials": { "steel_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 30, + "time_per_item": 60 + }, + "mithril_hoe": { + "type": "tool", + "display_name": "Mithril Hoe", + "level_required": 55, + "materials": { "mithril_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 40, + "time_per_item": 60 + }, + "adamantite_hoe": { + "type": "tool", + "display_name": "Adamantite Hoe", + "level_required": 70, + "materials": { "adamantite_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 50, + "time_per_item": 60 + }, + "runite_hoe": { + "type": "tool", + "display_name": "Runite Hoe", + "level_required": 85, + "materials": { "runite_bar": 2 }, + "output_quantity": 1, + "xp_per_item": 60, + "time_per_item": 60 + }, + "iron_nail": { + "type": "component", + "display_name": "Iron Nails", + "level_required": 5, + "materials": { "iron_bar": 1 }, + "output_quantity": 15, + "xp_per_item": 5.0, + "time_per_item": 60 + }, + "steel_nail": { + "type": "component", + "display_name": "Steel Nails", + "level_required": 30, + "materials": { "steel_bar": 1 }, + "output_quantity": 20, + "xp_per_item": 12.0, + "time_per_item": 60 + }, + "mithril_nail": { + "type": "component", + "display_name": "Mithril Nails", + "level_required": 55, + "materials": { "mithril_bar": 1 }, + "output_quantity": 25, + "xp_per_item": 25.0, + "time_per_item": 60 + } +} \ No newline at end of file diff --git a/scripts/sync_game_data.py b/scripts/sync_game_data.py new file mode 100644 index 0000000..0948b9d --- /dev/null +++ b/scripts/sync_game_data.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Download recipe JSON assets from the Idle Fantasy game repository.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +REPO = "tristinbaker/IdleFantasy" +BRANCH = "main" +DATA_PREFIX = f"app/src/main/assets/data/recipes" +RAW_BASE = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}" + +RECIPE_FILES = ( + "smithing.json", + "crafting.json", + "cooking.json", + "fletching.json", + "herblore.json", + "construction.json", +) + +ROOT = Path(__file__).resolve().parent.parent +OUT_DIR = ROOT / "game_data" / "recipes" +MANIFEST = ROOT / "game_data" / "manifest.json" + + +def _fetch(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "idle-fantasy-viewer-sync"}) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read() + + +def _resolve_sha() -> str: + api = f"https://api.github.com/repos/{REPO}/commits/{BRANCH}" + req = urllib.request.Request(api, headers={"User-Agent": "idle-fantasy-viewer-sync"}) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + return data["sha"] + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + sha = _resolve_sha() + synced: list[str] = [] + + for name in RECIPE_FILES: + url = f"{RAW_BASE}/{DATA_PREFIX}/{name}" + try: + payload = _fetch(url) + except urllib.error.HTTPError as exc: + raise SystemExit(f"Failed to download {name}: HTTP {exc.code}") from exc + json.loads(payload) + (OUT_DIR / name).write_bytes(payload) + synced.append(name) + print(f" {name}") + + MANIFEST.write_text( + json.dumps( + { + "source_repo": f"https://github.com/{REPO}", + "source_ref": BRANCH, + "source_sha": sha, + "synced_at": datetime.now(timezone.utc).isoformat(), + "files": synced, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + print(f"Synced {len(synced)} files -> {OUT_DIR}") + print(f"Manifest: {MANIFEST} (sha {sha[:12]})") + + +if __name__ == "__main__": + main() diff --git a/static/app.js b/static/app.js index 53e21cc..f684f34 100644 --- a/static/app.js +++ b/static/app.js @@ -5,7 +5,7 @@ let state = { snapshots: [], timeline: [], inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() }, - skills: { search: "", sort: "level", sortAsc: false }, + skills: { search: "", sort: "level", sortAsc: false, advisorKey: null, advisor: null, advisorLoading: false }, quests: { tab: "story", filter: "all" }, goals: { filter: "all", collapsedGroups: new Set(), data: { groups: [], ungrouped: [] } }, goalsOverview: null, @@ -580,6 +580,11 @@ function renderSkills(d) { +
+

+

+
+
@@ -628,6 +633,8 @@ function renderSkills(d) { document.getElementById("skill-search").value = s.search; document.getElementById("skill-sort").value = s.sort; + const advisorTitle = document.getElementById("skill-advisor-title"); + if (advisorTitle) advisorTitle.textContent = t("skills.advisorTitle"); ensureSkillTimeline().then(() => renderSkillsBody(d)); } @@ -670,8 +677,9 @@ function renderSkillsBody(d) { document.getElementById("skill-tbody").innerHTML = items.map((sk) => { const hasGoal = openGoals.skills.has(sk.key); + const selected = state.skills.advisorKey === sk.key; return ` - + ${showTrend ? renderSkillSparkCell(sk) : ""} @@ -689,7 +697,8 @@ function renderSkillsBody(d) { bindSparklines(document.getElementById("skill-tbody")); document.getElementById("skill-tbody").querySelectorAll(".goal-add-btn").forEach((btn) => { - btn.addEventListener("click", () => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); openGoalModal({ key: btn.dataset.skillKey, name: btn.dataset.skillName, @@ -698,6 +707,207 @@ function renderSkillsBody(d) { }); }); }); + + document.getElementById("skill-tbody").querySelectorAll(".skill-row").forEach((row) => { + const openAdvisor = () => { + const key = row.dataset.skillKey; + if (!key) return; + state.skills.advisorKey = key; + loadSkillAdvisor(key).then(() => renderSkillsBody(state.data)); + }; + row.addEventListener("click", (e) => { + if (e.target.closest(".goal-add-btn, .inv-spark-btn")) return; + openAdvisor(); + }); + row.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + openAdvisor(); + }); + }); + + renderSkillAdvisorCard(); +} + +async function loadSkillAdvisor(skillKey) { + state.skills.advisorLoading = true; + renderSkillAdvisorCard(); + try { + const res = await fetch(`${apiBase()}/advisor/${encodeURIComponent(skillKey)}`); + state.skills.advisor = res.ok ? await res.json() : { supported: false, error: "load_failed" }; + } catch { + state.skills.advisor = { supported: false, error: "load_failed" }; + } finally { + state.skills.advisorLoading = false; + } +} + +function formatAdvisorMaterials(materials) { + return Object.entries(materials || {}) + .map(([key, qty]) => `${qty}× ${key.replace(/_/g, " ")}`) + .join(", "); +} + +function formatAdvisorMissing(missing) { + return Object.entries(missing || {}) + .map(([key, qty]) => `${qty}× ${key.replace(/_/g, " ")}`) + .join(", "); +} + +function renderSkillAdvisorCard() { + const hint = document.getElementById("skill-advisor-hint"); + const body = document.getElementById("skill-advisor-body"); + if (!hint || !body) return; + + const adv = state.skills.advisor; + const key = state.skills.advisorKey; + + if (!key) { + hint.hidden = false; + hint.textContent = t("skills.advisorHint"); + body.innerHTML = ""; + return; + } + + if (state.skills.advisorLoading) { + hint.hidden = true; + body.innerHTML = `

${esc(t("skills.advisorLoading"))}

`; + return; + } + + if (!adv || !adv.supported) { + hint.hidden = false; + hint.textContent = adv?.error === "unsupported_skill" + ? t("skills.advisorUnsupported") + : t("skills.advisorUnavailable"); + body.innerHTML = ""; + return; + } + + hint.hidden = true; + const xpRemain = adv.xp_remaining_in_level ?? 0; + const summary = t("skills.advisorSummary", { + name: adv.skill_name, + level: adv.skill_level, + xp: fmt(xpRemain), + }); + + if (!adv.recommendations?.length) { + body.innerHTML = `

${esc(summary)}

${esc(t("skills.advisorNoRecipes"))}

`; + return; + } + + const rows = adv.recommendations.map((rec) => { + const mats = rec.can_craft + ? formatAdvisorMaterials(rec.materials) + : t("skills.advisorMissing", { items: formatAdvisorMissing(rec.missing_materials) }); + const eta = rec.eta_minutes_to_level > 0 + ? t("skills.advisorEta", { minutes: fmt(rec.eta_minutes_to_level) }) + : "—"; + const crafts = rec.crafts_to_next_level || 1; + const goalLabel = t("skills.advisorAdoptGoalFor", { name: rec.display_name, count: fmt(crafts) }); + return ` + + + + + + + `; + }).join(""); + + const skillGoalLabel = t("skills.advisorAdoptSkillGoal", { level: adv.skill_level + 1 }); + body.innerHTML = ` +
+

${esc(summary)}

+ +
+
+
${esc(sk.name)}${hasGoal ? `🎯` : ""}${sk.level}
${esc(rec.display_name)}${rec.xp_per_minute.toFixed(1)}${rec.level_required}${esc(mats)}${esc(eta)} + +
+ + + + + + + + + ${rows} +
${esc(t("skills.advisorActivity"))}${esc(t("skills.advisorXpMin"))}${esc(t("skills.advisorReqLevel"))}${esc(t("skills.advisorMaterials"))}${esc(t("skills.advisorEtaCol"))}${esc(t("goals.actions"))}
+
`; + + body.querySelector(".skill-advisor-skill-goal-btn")?.addEventListener("click", () => { + adoptAdvisorSkillGoal(); + }); + body.querySelectorAll(".skill-advisor-goal-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + adoptAdvisorItemGoal(btn.dataset.activityKey, Number(btn.dataset.crafts)); + }); + }); +} + +async function adoptAdvisorSkillGoal() { + const adv = state.skills.advisor; + if (!adv?.supported || !adv.skill_key) return; + const targetLevel = adv.skill_level + 1; + const res = await fetch(`${apiBase()}/goals`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + goal_type: "skill", + skill_key: adv.skill_key, + target_level: targetLevel, + mode: "absolute", + }), + }); + const result = await res.json(); + if (!res.ok) { + alert(result.error || t("goals.createFailed")); + return; + } + trackEvent("Goal Create", { source: "advisor", type: "skill", mode: "absolute" }); + await refreshGoalsAfterCreate(); + alert(t("skills.advisorGoalCreated", { name: adv.skill_name, target: targetLevel })); +} + +async function adoptAdvisorItemGoal(activityKey, crafts) { + const adv = state.skills.advisor; + if (!adv?.supported || !activityKey) return; + const rec = adv.recommendations?.find((r) => r.activity_key === activityKey); + const name = rec?.display_name || activityKey.replace(/_/g, " "); + const count = crafts > 0 ? crafts : 1; + const res = await fetch(`${apiBase()}/goals`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + item_key: activityKey, + target_qty: count, + mode: "relative", + }), + }); + const result = await res.json(); + if (!res.ok) { + alert(result.error || t("goals.createFailed")); + return; + } + trackEvent("Goal Create", { source: "advisor", type: "item", mode: "relative" }); + await refreshGoalsAfterCreate(); + alert(t("skills.advisorItemGoalCreated", { name, count: fmt(count) })); +} + +async function refreshGoalsAfterCreate() { + await loadGoals(); + const overviewRes = await fetch(`${apiBase()}/goals/overview`); + if (overviewRes.ok) state.goalsOverview = await overviewRes.json(); + renderGoals(); + if (state.data) { + renderHeader(state.data); + renderSkills(state.data); + renderInventory(state.data); + } } function getFilteredInventoryItems(d, inv) { diff --git a/static/i18n.js b/static/i18n.js index 4c48147..35e1aa2 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -2,6 +2,7 @@ const I18n = (() => { const STORAGE_KEY = "locale"; + const LOCALE_VERSION = "7"; const SUPPORTED = ["en", "de"]; let locale = "en"; let preference = "auto"; @@ -13,7 +14,7 @@ const I18n = (() => { } async function loadMessages(code) { - const res = await fetch(`/static/locales/${code}.json`); + const res = await fetch(`/static/locales/${code}.json?v=${LOCALE_VERSION}`, { cache: "no-store" }); if (!res.ok) throw new Error(`Locale not found: ${code}`); return res.json(); } diff --git a/static/locales/de.json b/static/locales/de.json index b677b71..4c49925 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -121,7 +121,25 @@ "addGoalFor": "Ziel für {name} hinzufügen", "trend": "Verlauf", "trendExpand": "Klicken für großes Diagramm", - "trendExpandFor": "Level-Verlauf für {name}" + "trendExpandFor": "Level-Verlauf für {name}", + "advisorTitle": "Trainings-Empfehlungen", + "advisorHint": "Skill in der Tabelle anklicken für XP-Empfehlungen (nur Rezept-Skills).", + "advisorLoading": "Lade Empfehlungen…", + "advisorUnsupported": "Für diesen Skill liegen noch keine Rezeptdaten vor.", + "advisorUnavailable": "Empfehlungen konnten nicht geladen werden.", + "advisorSummary": "{name} (Level {level}) — noch {xp} XP bis zum nächsten Level", + "advisorNoRecipes": "Keine Aktivitäten auf deinem aktuellen Level freigeschaltet.", + "advisorActivity": "Aktivität", + "advisorXpMin": "XP / Min.", + "advisorReqLevel": "Level", + "advisorMaterials": "Material", + "advisorEtaCol": "Bis Level", + "advisorEta": "~{minutes} Min.", + "advisorMissing": "Fehlt: {items}", + "advisorAdoptSkillGoal": "Level {level} als Ziel", + "advisorAdoptGoalFor": "Ziel: {count}× {name} craften (bis nächstes Level)", + "advisorGoalCreated": "Skill-Ziel angelegt: {name} → Level {target}", + "advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften" }, "inventory": { "search": "Item suchen…", diff --git a/static/locales/en.json b/static/locales/en.json index 639b3ee..e0afef3 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -121,7 +121,25 @@ "addGoalFor": "Add goal for {name}", "trend": "Trend", "trendExpand": "Click to enlarge chart", - "trendExpandFor": "Level history for {name}" + "trendExpandFor": "Level history for {name}", + "advisorTitle": "Training advisor", + "advisorHint": "Click a skill in the table for XP training recommendations (recipe skills only).", + "advisorLoading": "Loading recommendations…", + "advisorUnsupported": "No recipe data for this skill yet.", + "advisorUnavailable": "Could not load recommendations.", + "advisorSummary": "{name} (level {level}) — {xp} XP to next level", + "advisorNoRecipes": "No activities unlocked at your current level.", + "advisorActivity": "Activity", + "advisorXpMin": "XP / min", + "advisorReqLevel": "Req. level", + "advisorMaterials": "Materials", + "advisorEtaCol": "Est. to level", + "advisorEta": "~{minutes} min", + "advisorMissing": "Missing: {items}", + "advisorAdoptSkillGoal": "Level {level} as goal", + "advisorAdoptGoalFor": "Goal: craft {count}× {name} (to next level)", + "advisorGoalCreated": "Skill goal created: {name} → level {target}", + "advisorItemGoalCreated": "Item goal created: craft {count}× {name}" }, "inventory": { "search": "Search items…", diff --git a/static/style.css b/static/style.css index 3058a48..a8562e7 100644 --- a/static/style.css +++ b/static/style.css @@ -348,6 +348,86 @@ th { th:hover { color: var(--text); } tr:hover td { background: var(--bg-hover); } +.skills-table .skill-row { + cursor: pointer; +} + +.skills-table .skill-row-selected td { + background: rgba(108, 140, 255, 0.12); +} + +.skills-table .skill-row-selected:hover td { + background: rgba(108, 140, 255, 0.18); +} + +.skill-advisor-card h3 { + margin-bottom: 8px; +} + +.skill-advisor-hint { + margin: 0 0 12px; + color: var(--text-muted); + font-size: 0.88rem; +} + +.skill-advisor-summary { + margin: 0 0 12px; + font-size: 0.9rem; +} + +.skill-advisor-table th { + cursor: default; +} + +.skill-advisor-table th:hover { + color: var(--text-muted); +} + +.skill-advisor-table td.num { + white-space: nowrap; +} + +.skill-advisor-mats { + font-size: 0.82rem; + color: var(--text-muted); + max-width: 16rem; +} + +.skill-advisor-summary-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.skill-advisor-summary-row .skill-advisor-summary { + margin: 0; + flex: 1 1 12rem; +} + +.skill-advisor-skill-goal-btn { + flex-shrink: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-hover); + color: var(--text); + font-size: 0.85rem; + cursor: pointer; +} + +.skill-advisor-skill-goal-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.skill-advisor-table .col-actions { + width: 3rem; + text-align: center; +} + .progress-bar { height: 6px; background: var(--bg); diff --git a/static/sw.js b/static/sw.js index 16292b7..f241b73 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,4 +1,4 @@ -const CACHE = "if-viewer-static-v6"; +const CACHE = "if-viewer-static-v7"; const ASSETS = [ "/static/style.css", "/static/favicon.svg", @@ -13,6 +13,10 @@ const ASSETS = [ const NETWORK_FIRST = new Set([ "/static/pwa.js", "/static/style.css", + "/static/i18n.js", + "/static/locales/en.json", + "/static/locales/de.json", + "/static/app.js", ]); self.addEventListener("install", (event) => { diff --git a/test_advisor.py b/test_advisor.py new file mode 100644 index 0000000..a70979b --- /dev/null +++ b/test_advisor.py @@ -0,0 +1,65 @@ +"""Tests for skill training advisor.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from advisor import advise_skill, crafts_to_next_level, missing_materials +from game_data import recipes_for_skill, xp_per_minute +from parser import load_save, normalize_save + + +class AdvisorTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.save_path = Path(__file__).parent / "testfiles" / "fantasyidler_save-3.json" + if not cls.save_path.is_file(): + raise unittest.SkipTest("test save not available") + raw, _ = load_save(cls.save_path) + cls.snapshot = normalize_save(raw, source_file=str(cls.save_path)) + + def test_recipes_loaded(self) -> None: + recipes = recipes_for_skill("smithing") + self.assertIn("iron_bar", recipes) + self.assertAlmostEqual(xp_per_minute(recipes["iron_bar"]), 12.5) + + def test_missing_materials(self) -> None: + missing = missing_materials({"iron_ore": 5}, {"iron_ore": 2}) + self.assertEqual(missing, {"iron_ore": 3}) + + def test_smithing_advisor_level_57(self) -> None: + result = advise_skill("smithing", self.snapshot, limit=5) + self.assertTrue(result["supported"]) + self.assertEqual(result["skill_level"], 57) + recs = result["recommendations"] + self.assertGreater(len(recs), 0) + self.assertEqual(recs[0]["activity_key"], "steel_platebody") + self.assertEqual(recs[0]["xp_per_minute"], 187.5) + + def test_crafts_to_next_level(self) -> None: + self.assertEqual(crafts_to_next_level(100, 12.5), 8) + + def test_item_goal_from_recipe_not_in_inventory(self) -> None: + from db import create_goal, get_connection, import_save, init_db + + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "goal.db" + import_save(self.save_path, db_path=db) + conn = get_connection(db) + init_db(conn) + conn.close() + goal = create_goal("steel_platebody", 5, mode="relative", db_path=db) + self.assertEqual(goal["item_key"], "steel_platebody") + self.assertEqual(goal["mode"], "relative") + self.assertEqual(goal["target_qty"], 5) + + def test_unsupported_skill(self) -> None: + result = advise_skill("combat", self.snapshot) + self.assertFalse(result.get("supported")) + + +if __name__ == "__main__": + unittest.main()