"""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)