4b8b921e02
Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
7.2 KiB
Python
225 lines
7.2 KiB
Python
"""Parse and normalize Idle Fantasy Android save files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from categories import categorize_item
|
|
|
|
|
|
def _maybe_parse_json(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
if stripped.startswith(("{", "[")):
|
|
try:
|
|
return json.loads(stripped)
|
|
except json.JSONDecodeError:
|
|
return value
|
|
return value
|
|
|
|
|
|
def _deep_parse(obj: Any) -> Any:
|
|
if isinstance(obj, dict):
|
|
return {k: _deep_parse(_maybe_parse_json(v)) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_deep_parse(_maybe_parse_json(v)) for v in obj]
|
|
return obj
|
|
|
|
|
|
def xp_for_level(level: int) -> int:
|
|
"""Approximate cumulative XP threshold (OSRS-style curve)."""
|
|
if level <= 1:
|
|
return 0
|
|
total = 0
|
|
for lv in range(1, level):
|
|
total += int(lv + 300 * (2 ** (lv / 7.0)))
|
|
return total // 4
|
|
|
|
|
|
def xp_to_next_level(level: int, xp: int) -> dict[str, int]:
|
|
current_threshold = xp_for_level(level)
|
|
next_threshold = xp_for_level(level + 1)
|
|
span = max(next_threshold - current_threshold, 1)
|
|
progress = max(0, min(xp - current_threshold, span))
|
|
return {
|
|
"xp_in_level": progress,
|
|
"xp_needed": span,
|
|
"progress_pct": round(100 * progress / span, 1),
|
|
}
|
|
|
|
|
|
def format_item_name(key: str) -> str:
|
|
return key.replace("_", " ").title()
|
|
|
|
|
|
def format_key(key: str) -> str:
|
|
return key.replace("_", " ").title()
|
|
|
|
|
|
def load_save(path: str | Path) -> dict[str, Any]:
|
|
path = Path(path)
|
|
with path.open(encoding="utf-8") as f:
|
|
raw = json.load(f)
|
|
return _deep_parse(raw)
|
|
|
|
|
|
def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]:
|
|
flags = raw.get("flags") or {}
|
|
skill_levels = raw.get("skillLevels") or {}
|
|
skill_xp = raw.get("skillXp") or {}
|
|
inventory = raw.get("inventory") or {}
|
|
equipped = raw.get("equipped") or {}
|
|
equipped_values = {v for v in equipped.values() if v}
|
|
|
|
inventory_items = []
|
|
for key, qty in sorted(inventory.items()):
|
|
if qty <= 0:
|
|
continue
|
|
inventory_items.append({
|
|
"key": key,
|
|
"name": format_item_name(key),
|
|
"qty": qty,
|
|
"category": categorize_item(key),
|
|
"equipped": key in equipped_values,
|
|
})
|
|
|
|
skills = []
|
|
total_level = 0
|
|
for key in sorted(skill_levels.keys()):
|
|
level = int(skill_levels[key])
|
|
xp = int(skill_xp.get(key, 0))
|
|
total_level += level
|
|
prog = xp_to_next_level(level, xp)
|
|
skills.append({
|
|
"key": key,
|
|
"name": format_key(key),
|
|
"level": level,
|
|
"xp": xp,
|
|
**prog,
|
|
})
|
|
|
|
equipment = []
|
|
for slot, item_key in equipped.items():
|
|
equipment.append({
|
|
"slot": slot,
|
|
"slot_name": format_key(slot),
|
|
"key": item_key,
|
|
"name": format_item_name(item_key) if item_key else None,
|
|
})
|
|
|
|
story_quests = []
|
|
for q in raw.get("questProgress") or []:
|
|
story_quests.append({
|
|
"id": q.get("questId"),
|
|
"name": format_key(q.get("questId", "")),
|
|
"progress": q.get("progress", 0),
|
|
"completed": bool(q.get("completed")),
|
|
"completed_at": q.get("completedAt"),
|
|
})
|
|
|
|
daily_quests = _build_flag_quests(
|
|
flags.get("daily_quest_ids") or [],
|
|
flags.get("daily_quest_progress") or {},
|
|
flags.get("daily_quest_claimed") or [],
|
|
)
|
|
weekly_quests = _build_flag_quests(
|
|
flags.get("weekly_quest_ids") or [],
|
|
flags.get("weekly_quest_progress") or {},
|
|
flags.get("weekly_quest_claimed") or [],
|
|
)
|
|
guild_quests = _build_flag_quests(
|
|
flags.get("guild_daily_ids") or [],
|
|
flags.get("guild_daily_progress") or {},
|
|
flags.get("guild_daily_claimed") or [],
|
|
)
|
|
|
|
sessions = []
|
|
for s in raw.get("sessions") or []:
|
|
frames = s.get("frames") or []
|
|
total_xp = sum(f.get("xp_gain", 0) for f in frames) if isinstance(frames, list) else 0
|
|
total_kills = sum(f.get("kills", 0) for f in frames) if isinstance(frames, list) else 0
|
|
sessions.append({
|
|
"id": s.get("session_id"),
|
|
"skill": s.get("skill_name"),
|
|
"activity": s.get("activity_key"),
|
|
"started_at": s.get("started_at"),
|
|
"ends_at": s.get("ends_at"),
|
|
"completed": s.get("completed"),
|
|
"total_xp": total_xp,
|
|
"total_kills": total_kills,
|
|
"frame_count": len(frames) if isinstance(frames, list) else 0,
|
|
})
|
|
|
|
return {
|
|
"character": {
|
|
"name": flags.get("character_name"),
|
|
"gender": flags.get("character_gender"),
|
|
"race": flags.get("character_race"),
|
|
"hp": flags.get("current_hp"),
|
|
"active_potion": flags.get("active_potion_key"),
|
|
"active_spell": flags.get("active_spell"),
|
|
"active_weapon_slot": flags.get("active_weapon_slot"),
|
|
"active_blessing": flags.get("active_blessing_key"),
|
|
"blessing_expires_at": flags.get("active_blessing_expires_at"),
|
|
"theme": flags.get("theme_preference"),
|
|
},
|
|
"skills": skills,
|
|
"inventory": inventory_items,
|
|
"equipment": equipment,
|
|
"quests": {
|
|
"story": story_quests,
|
|
"daily": daily_quests,
|
|
"weekly": weekly_quests,
|
|
"guild": guild_quests,
|
|
},
|
|
"combat": {
|
|
"enemy_kills": flags.get("enemy_kills") or {},
|
|
"dungeon_runs": flags.get("dungeon_runs") or {},
|
|
"slayer_task": flags.get("active_slayer_task"),
|
|
"slayer_points": flags.get("slayer_points", 0),
|
|
},
|
|
"guild_reputation": flags.get("guild_reputation") or {},
|
|
"pets": raw.get("pets") or [],
|
|
"farming": raw.get("farmingPatches") or [],
|
|
"farming_fertilizer": flags.get("farming_fertilizer") or {},
|
|
"session_queue": flags.get("session_queue") or [],
|
|
"recent_sessions": flags.get("recent_sessions") or [],
|
|
"sessions": sessions,
|
|
"town_buildings": flags.get("town_building_tiers") or {},
|
|
"meta": {
|
|
"source_file": source_file,
|
|
"exported_at": raw.get("exported_at"),
|
|
"coins": raw.get("coins", 0),
|
|
"inventory_coins": inventory.get("coins", 0),
|
|
"total_level": total_level,
|
|
"item_count": len(inventory_items),
|
|
"total_items": sum(i["qty"] for i in inventory_items),
|
|
"version_code": flags.get("last_seen_version_code"),
|
|
},
|
|
}
|
|
|
|
|
|
def _build_flag_quests(
|
|
ids: list[str],
|
|
progress: dict[str, int],
|
|
claimed: list[str],
|
|
) -> list[dict[str, Any]]:
|
|
claimed_set = set(claimed or [])
|
|
result = []
|
|
for qid in ids:
|
|
result.append({
|
|
"id": qid,
|
|
"name": format_key(qid),
|
|
"progress": progress.get(qid, 0),
|
|
"claimed": qid in claimed_set,
|
|
})
|
|
return result
|
|
|
|
|
|
def parse_save_file(path: str | Path) -> dict[str, Any]:
|
|
path = Path(path)
|
|
raw = load_save(path)
|
|
return normalize_save(raw, source_file=path.name)
|