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>
This commit is contained in:
+175
@@ -0,0 +1,175 @@
|
||||
"""Save validation and import issue reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Known top-level fields (viewer baseline) – new fields are OK, reported as info.
|
||||
KNOWN_TOP_LEVEL_KEYS = frozenset({
|
||||
"skillLevels", "skillXp", "inventory", "equipped", "flags", "pets", "coins",
|
||||
"questProgress", "farmingPatches", "sessions", "exported_at",
|
||||
})
|
||||
|
||||
IMPORTANT_TOP_LEVEL_KEYS = frozenset({
|
||||
"skillLevels", "skillXp", "inventory", "flags",
|
||||
})
|
||||
|
||||
Issue = dict[str, Any]
|
||||
|
||||
|
||||
def issue(
|
||||
level: str,
|
||||
code: str,
|
||||
message: str,
|
||||
field: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Issue:
|
||||
out: Issue = {"level": level, "code": code, "message": message, "field": field}
|
||||
if params:
|
||||
out["params"] = params
|
||||
return out
|
||||
|
||||
|
||||
def has_errors(issues: list[Issue]) -> bool:
|
||||
return any(i["level"] == "error" for i in issues)
|
||||
|
||||
|
||||
def analyze_save(raw: Any, nested_parse_failures: list[str] | None = None) -> list[Issue]:
|
||||
"""Structural checks before normalization."""
|
||||
issues: list[Issue] = []
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
issues.append(issue(
|
||||
"error", "invalid_root",
|
||||
"The file is not a JSON object – not a valid Idle Fantasy backup.",
|
||||
))
|
||||
return issues
|
||||
|
||||
if not raw:
|
||||
issues.append(issue("error", "empty_save", "The save file is empty."))
|
||||
return issues
|
||||
|
||||
present = set(raw.keys())
|
||||
for key in sorted(present - KNOWN_TOP_LEVEL_KEYS):
|
||||
issues.append(issue(
|
||||
"info", "unknown_top_level",
|
||||
f'Unknown field in backup: "{key}" (added by a game update?).',
|
||||
field=key,
|
||||
params={"field": key},
|
||||
))
|
||||
|
||||
for key in sorted(IMPORTANT_TOP_LEVEL_KEYS - present):
|
||||
issues.append(issue(
|
||||
"warning", "missing_field",
|
||||
f'Expected field missing: "{key}" – related data will be shown empty.',
|
||||
field=key,
|
||||
params={"field": key},
|
||||
))
|
||||
|
||||
for field in nested_parse_failures or []:
|
||||
issues.append(issue(
|
||||
"warning", "nested_json_invalid",
|
||||
f'Field "{field}" could not be read as JSON – raw value ignored.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
|
||||
_check_dict_field(raw, "skillLevels", issues)
|
||||
_check_dict_field(raw, "skillXp", issues)
|
||||
_check_dict_field(raw, "inventory", issues)
|
||||
_check_dict_field(raw, "equipped", issues)
|
||||
_check_dict_field(raw, "flags", issues)
|
||||
_check_list_field(raw, "questProgress", issues)
|
||||
_check_list_field(raw, "farmingPatches", issues)
|
||||
_check_list_field(raw, "sessions", issues)
|
||||
|
||||
if "coins" in raw and not _is_number(raw["coins"]):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_coins",
|
||||
'Field "coins" is not numeric.',
|
||||
field="coins",
|
||||
))
|
||||
|
||||
if "exported_at" in raw and not _is_number(raw["exported_at"]):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_exported_at",
|
||||
'Field "exported_at" is not a valid timestamp.',
|
||||
field="exported_at",
|
||||
))
|
||||
elif "exported_at" not in raw:
|
||||
issues.append(issue(
|
||||
"warning", "missing_exported_at",
|
||||
"No export timestamp – history comparisons may be inaccurate.",
|
||||
field="exported_at",
|
||||
))
|
||||
|
||||
skill_levels = raw.get("skillLevels")
|
||||
skill_xp = raw.get("skillXp")
|
||||
if isinstance(skill_levels, dict) and isinstance(skill_xp, dict):
|
||||
only_levels = set(skill_levels) - set(skill_xp)
|
||||
only_xp = set(skill_xp) - set(skill_levels)
|
||||
if only_levels:
|
||||
examples = ", ".join(sorted(only_levels)[:3])
|
||||
issues.append(issue(
|
||||
"info", "skill_xp_mismatch",
|
||||
f"{len(only_levels)} skill(s) without XP entry (e.g. {examples}).",
|
||||
field="skillXp",
|
||||
params={"count": len(only_levels), "examples": examples},
|
||||
))
|
||||
if only_xp:
|
||||
issues.append(issue(
|
||||
"info", "skill_level_mismatch",
|
||||
f"{len(only_xp)} XP entries without skill level.",
|
||||
field="skillLevels",
|
||||
params={"count": len(only_xp)},
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _check_dict_field(raw: dict, field: str, issues: list[Issue]) -> None:
|
||||
if field not in raw:
|
||||
return
|
||||
value = raw[field]
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
issues.append(issue(
|
||||
"warning", "unparsed_nested_json",
|
||||
f'Field "{field}" is still a text string – JSON content could not be read.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
elif not isinstance(value, dict):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_type",
|
||||
f'Field "{field}" has unexpected type ({type(value).__name__}).',
|
||||
field=field,
|
||||
params={"field": field, "type": type(value).__name__},
|
||||
))
|
||||
|
||||
|
||||
def _check_list_field(raw: dict, field: str, issues: list[Issue]) -> None:
|
||||
if field not in raw:
|
||||
return
|
||||
value = raw[field]
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
issues.append(issue(
|
||||
"warning", "unparsed_nested_json",
|
||||
f'Field "{field}" is still a text string – JSON content could not be read.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
elif not isinstance(value, list):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_type",
|
||||
f'Field "{field}" has unexpected type ({type(value).__name__}).',
|
||||
field=field,
|
||||
params={"field": field, "type": type(value).__name__},
|
||||
))
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
Reference in New Issue
Block a user