Add skill training advisor with recipe data and one-click goals.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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/
|
||||
|
||||
|
||||
+120
@@ -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],
|
||||
}
|
||||
@@ -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/<skill_key>")
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
+213
-3
@@ -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) {
|
||||
<option value="name"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card skill-advisor-card" id="skill-advisor-card">
|
||||
<h3 id="skill-advisor-title"></h3>
|
||||
<p class="skill-advisor-hint" id="skill-advisor-hint"></p>
|
||||
<div id="skill-advisor-body"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="skills-table" id="skills-table">
|
||||
<thead><tr id="skills-thead-row">
|
||||
@@ -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 `
|
||||
<tr class="${hasGoal ? "has-goal" : ""}">
|
||||
<tr class="skill-row ${hasGoal ? "has-goal" : ""} ${selected ? "skill-row-selected" : ""}" data-skill-key="${esc(sk.key)}" tabindex="0" role="button">
|
||||
<td>${esc(sk.name)}${hasGoal ? `<span class="goal-mark" title="${esc(t("goals.hasGoal"))}">🎯</span>` : ""}</td>
|
||||
${showTrend ? renderSkillSparkCell(sk) : ""}
|
||||
<td>${sk.level}</td>
|
||||
@@ -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 = `<p class="loading">${esc(t("skills.advisorLoading"))}</p>`;
|
||||
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 = `<p class="skill-advisor-summary">${esc(summary)}</p><p class="empty-state">${esc(t("skills.advisorNoRecipes"))}</p>`;
|
||||
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 `<tr>
|
||||
<td>${esc(rec.display_name)}</td>
|
||||
<td class="num">${rec.xp_per_minute.toFixed(1)}</td>
|
||||
<td>${rec.level_required}</td>
|
||||
<td class="skill-advisor-mats">${esc(mats)}</td>
|
||||
<td class="num">${esc(eta)}</td>
|
||||
<td class="col-actions">
|
||||
<button type="button" class="goal-add-btn skill-advisor-goal-btn" data-activity-key="${esc(rec.activity_key)}" data-crafts="${crafts}" title="${esc(goalLabel)}" aria-label="${esc(goalLabel)}">+</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
const skillGoalLabel = t("skills.advisorAdoptSkillGoal", { level: adv.skill_level + 1 });
|
||||
body.innerHTML = `
|
||||
<div class="skill-advisor-summary-row">
|
||||
<p class="skill-advisor-summary">${esc(summary)}</p>
|
||||
<button type="button" class="btn-secondary skill-advisor-skill-goal-btn">${esc(skillGoalLabel)}</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="skill-advisor-table">
|
||||
<thead><tr>
|
||||
<th>${esc(t("skills.advisorActivity"))}</th>
|
||||
<th>${esc(t("skills.advisorXpMin"))}</th>
|
||||
<th>${esc(t("skills.advisorReqLevel"))}</th>
|
||||
<th>${esc(t("skills.advisorMaterials"))}</th>
|
||||
<th>${esc(t("skills.advisorEtaCol"))}</th>
|
||||
<th class="col-actions">${esc(t("goals.actions"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
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) {
|
||||
|
||||
+2
-1
@@ -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();
|
||||
}
|
||||
|
||||
+19
-1
@@ -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…",
|
||||
|
||||
+19
-1
@@ -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…",
|
||||
|
||||
@@ -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);
|
||||
|
||||
+5
-1
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user