Files
Idle-Fantasy-Save-Viewer/parser.py
T
elpatron fbc2deec45 Add i18n, save validation, and tolerant import handling.
Prepare the UI for English (default/fallback) and German with auto or manual locale selection, and report import issues with client-translated warnings instead of failing on minor save format changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 15:59:57 +02:00

422 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from validation import Issue, analyze_save, has_errors, issue
class SaveParseError(Exception):
"""Save cannot be imported."""
def __init__(self, message: str, issues: list[Issue] | None = None):
super().__init__(message)
self.issues = issues or []
def _maybe_parse_json(value: Any, field_path: str, failures: list[str]) -> Any:
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(("{", "[")):
try:
return json.loads(stripped)
except json.JSONDecodeError:
failures.append(field_path)
return value
return value
def _deep_parse(obj: Any, path: str = "", failures: list[str] | None = None) -> Any:
failures = failures if failures is not None else []
if isinstance(obj, dict):
return {
k: _deep_parse(_maybe_parse_json(v, f"{path}.{k}" if path else k, failures), f"{path}.{k}" if path else k, failures)
for k, v in obj.items()
}
if isinstance(obj, list):
return [
_deep_parse(_maybe_parse_json(v, f"{path}[{i}]", failures), f"{path}[{i}]", failures)
for i, v in enumerate(obj)
]
return obj
def _ensure_dict(value: Any, field: str, issues: list[Issue]) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return value
issues.append(issue(
"warning", "coerced_empty_dict",
f'Field "{field}" is not an object treated as empty.',
field=field,
params={"field": field},
))
return {}
def _ensure_list(value: Any, field: str, issues: list[Issue]) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
issues.append(issue(
"warning", "coerced_empty_list",
f'Field "{field}" is not a list skipped.',
field=field,
params={"field": field},
))
return []
def _safe_int(value: Any, field: str, issues: list[Issue], default: int = 0) -> int:
if value is None:
return default
if isinstance(value, bool):
issues.append(issue(
"warning", "invalid_number",
f'Invalid number in "{field}".',
field=field,
params={"field": field, "detail": ""},
))
return default
try:
return int(value)
except (TypeError, ValueError):
issues.append(issue(
"warning", "invalid_number",
f'Invalid number in "{field}": {value!r}.',
field=field,
params={"field": field, "detail": f": {value!r}"},
))
return default
def xp_for_level(level: int) -> int:
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 | float]:
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 str(key).replace("_", " ").title()
def format_key(key: str) -> str:
return str(key).replace("_", " ").title()
def load_save(path: str | Path) -> tuple[dict[str, Any], list[str]]:
path = Path(path)
try:
with path.open(encoding="utf-8") as f:
raw = json.load(f)
except json.JSONDecodeError as exc:
raise SaveParseError(f"Invalid JSON file: {exc}") from exc
except OSError as exc:
raise SaveParseError(f"Could not read file: {exc}") from exc
failures: list[str] = []
parsed = _deep_parse(raw, failures=failures)
if not isinstance(parsed, dict):
raise SaveParseError("The file must contain a JSON object.")
return parsed, failures
def normalize_save(
raw: dict[str, Any],
source_file: str = "",
issues: list[Issue] | None = None,
nested_failures: list[str] | None = None,
) -> dict[str, Any]:
issues = list(issues or [])
issues.extend(analyze_save(raw, nested_failures))
if has_errors(issues):
fatal = next(i for i in issues if i["level"] == "error")
raise SaveParseError(fatal["message"], issues)
flags = _ensure_dict(raw.get("flags"), "flags", issues)
skill_levels = _ensure_dict(raw.get("skillLevels"), "skillLevels", issues)
skill_xp = _ensure_dict(raw.get("skillXp"), "skillXp", issues)
inventory = _ensure_dict(raw.get("inventory"), "inventory", issues)
equipped = _ensure_dict(raw.get("equipped"), "equipped", issues)
equipped_values = {v for v in equipped.values() if v}
inventory_items = []
for key, qty_raw in sorted(inventory.items()):
qty = _safe_int(qty_raw, f"inventory.{key}", issues, default=-1)
if qty <= 0:
if qty < 0:
continue
continue
item_key = str(key)
inventory_items.append({
"key": item_key,
"name": format_item_name(item_key),
"qty": qty,
"category": categorize_item(item_key),
"equipped": item_key in equipped_values,
})
all_skill_keys = set(skill_levels) | set(skill_xp)
skills = []
total_level = 0
for key in sorted(all_skill_keys):
level = _safe_int(skill_levels.get(key, 1), f"skillLevels.{key}", issues, default=1)
xp = _safe_int(skill_xp.get(key, 0), f"skillXp.{key}", issues, default=0)
total_level += level
prog = xp_to_next_level(level, xp)
skills.append({
"key": str(key),
"name": format_key(str(key)),
"level": level,
"xp": xp,
**prog,
})
equipment = []
for slot, item_key in equipped.items():
equipment.append({
"slot": str(slot),
"slot_name": format_key(str(slot)),
"key": item_key,
"name": format_item_name(str(item_key)) if item_key else None,
})
story_quests = []
for idx, q in enumerate(_ensure_list(raw.get("questProgress"), "questProgress", issues)):
if not isinstance(q, dict):
issues.append(issue(
"warning", "invalid_quest_entry",
f"Quest entry #{idx + 1} is not an object and was skipped.",
field="questProgress",
params={"index": idx + 1},
))
continue
quest_id = q.get("questId") or q.get("id") or f"unknown_{idx}"
story_quests.append({
"id": quest_id,
"name": format_key(str(quest_id)),
"progress": _safe_int(q.get("progress"), f"questProgress[{idx}].progress", issues),
"completed": bool(q.get("completed")),
"completed_at": q.get("completedAt"),
})
daily_quests = _build_flag_quests(
flags.get("daily_quest_ids"),
flags.get("daily_quest_progress"),
flags.get("daily_quest_claimed"),
"daily_quest", issues,
)
weekly_quests = _build_flag_quests(
flags.get("weekly_quest_ids"),
flags.get("weekly_quest_progress"),
flags.get("weekly_quest_claimed"),
"weekly_quest", issues,
)
guild_quests = _build_flag_quests(
flags.get("guild_daily_ids"),
flags.get("guild_daily_progress"),
flags.get("guild_daily_claimed"),
"guild_daily", issues,
)
sessions = []
for idx, s in enumerate(_ensure_list(raw.get("sessions"), "sessions", issues)):
if not isinstance(s, dict):
issues.append(issue(
"warning", "invalid_session_entry",
f"Session entry #{idx + 1} is not an object and was skipped.",
field="sessions",
params={"index": idx + 1},
))
continue
frames = s.get("frames")
if isinstance(frames, str):
issues.append(issue(
"warning", "unparsed_session_frames",
f"Session #{idx + 1}: activity frames could not be read.",
field="sessions",
params={"index": idx + 1},
))
frames = []
elif not isinstance(frames, list):
frames = []
total_xp = sum(_safe_int(f.get("xp_gain"), f"sessions[{idx}].xp", issues) for f in frames if isinstance(f, dict))
total_kills = sum(_safe_int(f.get("kills"), f"sessions[{idx}].kills", issues) for f in frames if isinstance(f, dict))
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),
})
pets_raw = raw.get("pets")
pets: list[Any] = []
if isinstance(pets_raw, list):
pets = pets_raw
elif pets_raw is not None:
issues.append(issue("warning", "invalid_pets", 'Field "pets" is not a list.', field="pets"))
farming = []
for idx, patch in enumerate(_ensure_list(raw.get("farmingPatches"), "farmingPatches", issues)):
if isinstance(patch, dict):
farming.append(patch)
else:
issues.append(issue(
"warning", "invalid_farming_patch",
f"Farming patch #{idx + 1} was skipped.",
field="farmingPatches",
params={"index": idx + 1},
))
if not flags.get("character_name"):
issues.append(issue(
"warning", "missing_character_name",
"No character name found in save.",
field="flags.character_name",
))
# Deduplizieren (gleicher code+field+message)
seen = set()
unique_issues: list[Issue] = []
for item in issues:
key = (item["level"], item["code"], item.get("field"), item["message"])
if key not in seen:
seen.add(key)
unique_issues.append(item)
warning_count = sum(1 for i in unique_issues if i["level"] == "warning")
info_count = sum(1 for i in unique_issues if i["level"] == "info")
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": _ensure_dict(flags.get("enemy_kills"), "flags.enemy_kills", issues),
"dungeon_runs": _ensure_dict(flags.get("dungeon_runs"), "flags.dungeon_runs", issues),
"slayer_task": flags.get("active_slayer_task") if isinstance(flags.get("active_slayer_task"), dict) else None,
"slayer_points": _safe_int(flags.get("slayer_points"), "flags.slayer_points", issues),
},
"guild_reputation": _ensure_dict(flags.get("guild_reputation"), "flags.guild_reputation", issues),
"pets": pets,
"farming": farming,
"farming_fertilizer": _ensure_dict(flags.get("farming_fertilizer"), "flags.farming_fertilizer", issues),
"session_queue": _ensure_list(flags.get("session_queue"), "flags.session_queue", issues),
"recent_sessions": _ensure_list(flags.get("recent_sessions"), "flags.recent_sessions", issues),
"sessions": sessions,
"town_buildings": _ensure_dict(flags.get("town_building_tiers"), "flags.town_building_tiers", issues),
"meta": {
"source_file": source_file,
"exported_at": raw.get("exported_at"),
"coins": _safe_int(raw.get("coins"), "coins", issues),
"inventory_coins": _safe_int(inventory.get("coins"), "inventory.coins", issues),
"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"),
"import_report": unique_issues,
"import_summary": {
"ok": True,
"warnings": warning_count,
"infos": info_count,
},
},
# Unbekannte Top-Level-Felder für spätere Auswertung durchreichen
"extensions": {
k: raw[k] for k in raw if k not in {
"skillLevels", "skillXp", "inventory", "equipped", "flags",
"pets", "coins", "questProgress", "farmingPatches", "sessions", "exported_at",
}
} if isinstance(raw, dict) else {},
}
def _build_flag_quests(
ids: Any,
progress: Any,
claimed: Any,
label: str,
issues: list[Issue],
) -> list[dict[str, Any]]:
id_list = ids if isinstance(ids, list) else []
if ids is not None and not isinstance(ids, list):
issues.append(issue(
"warning", "invalid_quest_ids",
f"Quest IDs ({label}) are not a list.",
field=label,
params={"label": label},
))
progress_map = progress if isinstance(progress, dict) else {}
if progress is not None and not isinstance(progress, dict):
issues.append(issue(
"warning", "invalid_quest_progress",
f"Quest progress ({label}) is not an object.",
field=label,
params={"label": label},
))
claimed_list = claimed if isinstance(claimed, list) else []
claimed_set = set(claimed_list)
result = []
for qid in id_list:
qid_str = str(qid)
result.append({
"id": qid_str,
"name": format_key(qid_str),
"progress": _safe_int(progress_map.get(qid), f"{label}.{qid_str}", issues),
"claimed": qid_str in claimed_set or qid in claimed_set,
})
return result
def parse_save_file(path: str | Path) -> tuple[dict[str, Any], list[Issue]]:
path = Path(path)
raw, failures = load_save(path)
data = normalize_save(raw, source_file=path.name, nested_failures=failures)
return data, data["meta"]["import_report"]