Add skill training advisor with recipe data and one-click goals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 15:28:51 +02:00
parent e233e3c762
commit 567bbd3de0
21 changed files with 3447 additions and 18 deletions
+2 -1
View File
@@ -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
View File
@@ -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],
}
+10
View File
@@ -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)
+20 -10
View File
@@ -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(
+83
View File
@@ -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)
+11
View File
@@ -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.
+14
View File
@@ -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"
]
}
+82
View File
@@ -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
}
}
+155
View File
@@ -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
}
}
+356
View File
@@ -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
}
}
+458
View File
@@ -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
}
}
+146
View File
@@ -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
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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…",
+80
View File
@@ -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
View File
@@ -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) => {
+65
View File
@@ -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()