abdeab59e7
Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.9 KiB
Python
121 lines
3.9 KiB
Python
"""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 0
|
|
return 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],
|
|
}
|