Add season events and new save fields to the viewer.

Parse seasonal, tower, carnival, expeditions, and dungeon stats from game v113 saves and display them in new Events, Overview, and Combat sections, with token and tower deltas in import history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-16 11:30:26 +02:00
co-authored by Cursor
parent 3b47c16b74
commit 27b23149ce
8 changed files with 535 additions and 6 deletions
+24
View File
@@ -243,6 +243,20 @@ def get_latest_snapshot(conn: sqlite3.Connection | None = None, db_path: Path |
return json.loads(row["raw_json"])
def _seasonal_tokens_total(data: dict[str, Any]) -> int:
seasonal = data.get("seasonal") or {}
tokens = seasonal.get("tokens_by_event") or {}
event_id = seasonal.get("active_event_id")
if event_id and event_id in tokens:
return int(tokens[event_id])
return sum(int(v) for v in tokens.values())
def _tower_floor(data: dict[str, Any]) -> int:
tower = data.get("tower") or {}
return int(tower.get("current_floor") or 0)
def diff_snapshots(
older_id: int,
newer_id: int,
@@ -319,6 +333,9 @@ def diff_snapshots(
"xp_delta": new_s["xp"] - old_s["xp"],
})
older_data = get_snapshot(older_id, conn=conn, db_path=db_path) or {}
newer_data = get_snapshot(newer_id, conn=conn, db_path=db_path) or {}
if own_conn:
conn.close()
@@ -328,6 +345,8 @@ def diff_snapshots(
"summary": {
"coins_delta": newer_meta["coins"] - older_meta["coins"],
"total_level_delta": newer_meta["total_level"] - older_meta["total_level"],
"seasonal_tokens_delta": _seasonal_tokens_total(newer_data) - _seasonal_tokens_total(older_data),
"tower_floor_delta": _tower_floor(newer_data) - _tower_floor(older_data),
},
"inventory_changes": inventory_changes,
"skill_changes": skill_changes,
@@ -1035,6 +1054,9 @@ def summarize_import_changes(
if own_conn:
conn.close()
tokens_delta = _seasonal_tokens_total(newer_data) - _seasonal_tokens_total(older_data)
tower_delta = _tower_floor(newer_data) - _tower_floor(older_data)
return {
"has_previous": True,
"older_id": older_id,
@@ -1046,6 +1068,8 @@ def summarize_import_changes(
"quests_completed": quests_completed,
"slayer_kills_delta": max(0, slayer_delta),
"dungeon_runs_delta": dungeon_delta,
"seasonal_tokens_delta": tokens_delta,
"tower_floor_delta": tower_delta,
"top_inventory": diff["inventory_changes"][:5],
"top_skills": diff["skill_changes"][:5],
}
+94
View File
@@ -314,6 +314,54 @@ def normalize_save(
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")
dungeon_stats_raw = _ensure_dict(
flags.get("dungeon_last_run_stats"), "flags.dungeon_last_run_stats", issues,
)
dungeon_stats: dict[str, dict[str, Any]] = {}
for key, stats in dungeon_stats_raw.items():
if not isinstance(stats, dict):
continue
dungeon_stats[str(key)] = {
"food_consumed": _safe_int(
stats.get("food_consumed"), f"flags.dungeon_last_run_stats.{key}.food_consumed", issues,
),
"kill_count": _safe_int(
stats.get("kill_count"), f"flags.dungeon_last_run_stats.{key}.kill_count", issues,
),
"survived": bool(stats.get("survived")),
}
workers: list[dict[str, Any]] = []
for slot, flag_key in ((1, "hired_worker"), (2, "hired_worker_2")):
worker = flags.get(flag_key)
if isinstance(worker, dict) and worker:
workers.append({
"slot": slot,
"tier": worker.get("tier"),
"daily_name": worker.get("daily_name"),
"session_queue": _ensure_list(
worker.get("session_queue"), f"flags.{flag_key}.session_queue", issues,
),
})
carnival_cooldowns = {
"ring_toss": flags.get("carnival_ring_toss_cooldown_at"),
"hammer_strike": flags.get("carnival_hammer_strike_cooldown_at"),
"potion_sequence": flags.get("carnival_potion_sequence_cooldown_at"),
"item_appraisal": flags.get("carnival_item_appraisal_cooldown_at"),
"shell_game": flags.get("carnival_shell_game_cooldown_at"),
"higher_lower": flags.get("carnival_higher_lower_cooldown_at"),
}
unlocked_titles = flags.get("unlocked_titles")
if unlocked_titles is not None and not isinstance(unlocked_titles, list):
issues.append(issue(
"warning", "invalid_titles_list",
'Field "flags.unlocked_titles" is not a list.',
field="flags.unlocked_titles",
))
unlocked_titles = []
return {
"character": {
"name": flags.get("character_name"),
@@ -326,6 +374,8 @@ def normalize_save(
"active_blessing": flags.get("active_blessing_key"),
"blessing_expires_at": flags.get("active_blessing_expires_at"),
"theme": flags.get("theme_preference"),
"equipped_title": flags.get("equipped_title"),
"player_notes": flags.get("player_notes"),
},
"skills": skills,
"inventory": inventory_items,
@@ -350,6 +400,50 @@ def normalize_save(
"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),
"seasonal": {
"tokens_by_event": _ensure_dict(
flags.get("seasonal_tokens_by_event"), "flags.seasonal_tokens_by_event", issues,
),
"active_event_id": flags.get("seasonal_bounty_event_id"),
"bounty_slots": _ensure_list(flags.get("seasonal_bounty_slots"), "flags.seasonal_bounty_slots", issues),
"bounty_progress": _ensure_dict(
flags.get("seasonal_bounty_progress"), "flags.seasonal_bounty_progress", issues,
),
"bounty_cooldowns": _ensure_dict(
flags.get("seasonal_bounty_slot_cooldown"), "flags.seasonal_bounty_slot_cooldown", issues,
),
"minigame_cooldown_at": flags.get("seasonal_minigame_cooldown_at"),
"minigame_easy_mode": bool(flags.get("seasonal_minigame_easy_mode")),
"banners_earned": _ensure_list(flags.get("seasonal_banners_earned"), "flags.seasonal_banners_earned", issues),
},
"tower": {
"current_floor": _safe_int(flags.get("tower_current_floor"), "flags.tower_current_floor", issues),
"best_floor": _safe_int(flags.get("tower_best_floor"), "flags.tower_best_floor", issues),
"milestones": (
flags.get("tower_milestones")
if isinstance(flags.get("tower_milestones"), list)
else []
),
"xp_bonus_pct": _safe_int(flags.get("tower_xp_bonus_pct"), "flags.tower_xp_bonus_pct", issues),
"hp_bonus": _safe_int(flags.get("tower_hp_bonus"), "flags.tower_hp_bonus", issues),
"coin_bonus_pct": _safe_int(flags.get("tower_coin_bonus_pct"), "flags.tower_coin_bonus_pct", issues),
},
"carnival": {
"tab": _safe_int(flags.get("carnival_tab"), "flags.carnival_tab", issues),
"difficulties": _ensure_dict(flags.get("carnival_difficulties"), "flags.carnival_difficulties", issues),
"cooldowns": carnival_cooldowns,
},
"workers": workers,
"titles": {
"unlocked": unlocked_titles if isinstance(unlocked_titles, list) else [],
"equipped": flags.get("equipped_title"),
},
"expeditions": {
"unlocked": _ensure_list(flags.get("unlocked_dungeons"), "flags.unlocked_dungeons", issues),
"notes": _ensure_dict(flags.get("skilling_dungeon_notes"), "flags.skilling_dungeon_notes", issues),
"pity": _ensure_dict(flags.get("expedition_pity_runs"), "flags.expedition_pity_runs", issues),
},
"dungeon_stats": dungeon_stats,
"meta": {
"source_file": source_file,
"exported_at": raw.get("exported_at"),
+209
View File
@@ -487,6 +487,7 @@ function renderAll() {
renderEquipment(d);
renderQuests(d);
renderCombat(d);
renderEvents(d);
}
function renderHeader(d) {
@@ -525,6 +526,29 @@ function renderOverview(d) {
`<li><span>${esc(t("overview.patch", { n: p.patchNumber }))}</span><span>${esc(p.cropType || "—")}</span></li>`
).join("");
const tower = d.tower || {};
const towerHtml = tower.current_floor || tower.best_floor
? `<ul class="list-compact">
<li><span>${esc(t("overview.towerFloor"))}</span><span>${tower.current_floor} / ${tower.best_floor}</span></li>
<li><span>${esc(t("overview.towerMilestones"))}</span><span>${(tower.milestones || []).join(", ") || ""}</span></li>
<li><span>${esc(t("overview.towerBonuses"))}</span><span>XP +${tower.xp_bonus_pct}% · HP +${tower.hp_bonus} · ${esc(t("kpi.coins"))} +${tower.coin_bonus_pct}%</span></li>
</ul>`
: `<p class="empty-state">${esc(t("empty.none"))}</p>`;
const workersHtml = (d.workers || []).map((w) =>
`<li><span>${esc(t("overview.workerSlot", { slot: w.slot }))}</span><span>${esc(w.daily_name || "?")} (${esc(w.tier || "?")})</span></li>`
).join("");
const titles = d.titles || {};
const titlesHtml = `<ul class="list-compact">
<li><span>${esc(t("overview.titlesUnlocked"))}</span><span>${esc((titles.unlocked || []).map(humanizeKey).join(", ") || t("empty.none"))}</span></li>
<li><span>${esc(t("overview.titlesEquipped"))}</span><span>${esc(titles.equipped ? humanizeKey(titles.equipped) : t("events.noneEquipped"))}</span></li>
</ul>`;
const townHtml = Object.entries(d.town_buildings || {}).map(([k, v]) =>
`<li><span>${esc(humanizeKey(k))}</span><span>${esc(t("overview.buildingTier", { tier: v }))}</span></li>`
).join("");
document.getElementById("tab-overview").innerHTML = `
${renderImportChangesCard(state.lastImportChanges)}
<div class="grid-2">
@@ -559,6 +583,22 @@ function renderOverview(d) {
<ul class="list-compact">${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
`<li><span>${esc(k)}</span><span>${fmt(v)}</span></li>`).join("")}</ul>
</div>
<div class="card">
<h3>${esc(t("overview.tower"))}</h3>
${towerHtml}
</div>
<div class="card">
<h3>${esc(t("overview.worker"))}</h3>
<ul class="list-compact">${workersHtml || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>${esc(t("overview.titles"))}</h3>
${titlesHtml}
</div>
<div class="card">
<h3>${esc(t("overview.town"))}</h3>
<ul class="list-compact">${townHtml || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
</div>`;
}
@@ -587,6 +627,8 @@ function renderImportChangesCard(changes) {
${changes.quests_completed ? `<li><span>${esc(t("import.questsCompleted"))}</span><span>${changes.quests_completed}</span></li>` : ""}
${changes.slayer_kills_delta ? `<li><span>${esc(t("import.slayerKills"))}</span><span>+${changes.slayer_kills_delta}</span></li>` : ""}
${changes.dungeon_runs_delta ? `<li><span>${esc(t("import.dungeonRuns"))}</span><span>+${changes.dungeon_runs_delta}</span></li>` : ""}
${changes.seasonal_tokens_delta ? `<li><span>${esc(t("import.seasonalTokens"))}</span><span>${changes.seasonal_tokens_delta >= 0 ? "+" : ""}${fmt(changes.seasonal_tokens_delta)}</span></li>` : ""}
${changes.tower_floor_delta ? `<li><span>${esc(t("import.towerFloor"))}</span><span>${changes.tower_floor_delta >= 0 ? "+" : ""}${changes.tower_floor_delta}</span></li>` : ""}
</ul>
${invTop ? `<h4>${esc(t("import.topInventory"))}</h4><ul class="list-compact">${invTop}</ul>` : ""}
${skTop ? `<h4>${esc(t("import.topSkills"))}</h4><ul class="list-compact">${skTop}</ul>` : ""}
@@ -2004,6 +2046,78 @@ function renderQuests(d) {
});
}
function renderEvents(d) {
const seasonal = d.seasonal || {};
const carnival = d.carnival || {};
const carnivalSkill = (d.skills || []).find((s) => s.key === "carnival");
const carnivalTickets = (d.inventory || []).find((i) => i.key === "carnival_ticket");
const eventId = seasonal.active_event_id;
let seasonalHtml = `<p class="empty-state">${esc(t("events.noActiveEvent"))}</p>`;
if (eventId) {
const tokens = seasonal.tokens_by_event?.[eventId] ?? 0;
const bountySlots = (seasonal.bounty_slots || []).map((slot) => {
const progress = seasonal.bounty_progress?.[slot];
const cooldown = seasonal.bounty_cooldowns?.[slot];
let status = t("events.bountyOpen");
if (progress != null && progress > 0) {
status = t("events.bountyProgress", { progress: fmt(progress) });
} else if (cooldown) {
status = formatCooldown(cooldown);
}
return `<li><span>${esc(humanizeKey(slot))}</span><span>${esc(status)}</span></li>`;
}).join("");
const banners = (seasonal.banners_earned || []).map(humanizeKey).join(", ") || t("empty.none");
const minigameStatus = formatCooldown(seasonal.minigame_cooldown_at);
const easyBadge = seasonal.minigame_easy_mode
? `<span class="event-badge">${esc(t("events.easyMode"))}</span>`
: "";
seasonalHtml = `
<ul class="list-compact">
<li><span>${esc(t("events.tokens"))}</span><span>${fmt(tokens)}</span></li>
<li><span>${esc(t("events.minigame"))}</span><span>${esc(minigameStatus)} ${easyBadge}</span></li>
<li><span>${esc(t("events.banners"))}</span><span>${esc(banners)}</span></li>
</ul>
<h4>${esc(t("events.bounties"))}</h4>
<ul class="list-compact">${bountySlots || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>`;
}
const carnivalCooldowns = Object.entries(carnival.cooldowns || {})
.map(([key, ts]) => {
const labelKey = `events.carnival.${key}`;
const label = I18n.t(labelKey) !== labelKey ? t(labelKey) : humanizeKey(key);
return `<li><span>${esc(label)}</span><span>${esc(formatCooldown(ts))}</span></li>`;
})
.join("");
const difficulties = Object.entries(carnival.difficulties || {});
const diffHtml = difficulties.length
? `<h4>${esc(t("events.carnival.difficulties"))}</h4><ul class="list-compact">${difficulties.map(([k, v]) =>
`<li><span>${esc(humanizeKey(k))}</span><span>${esc(String(v))}</span></li>`
).join("")}</ul>`
: "";
document.getElementById("tab-events").innerHTML = `
<div class="grid-2">
<div class="card">
<h3>${esc(eventId ? humanizeKey(eventId) : t("events.seasonal"))}</h3>
${seasonalHtml}
</div>
<div class="card">
<h3>${esc(t("events.carnival.title"))}</h3>
<ul class="list-compact">
${carnivalSkill ? `<li><span>${esc(t("events.carnival.skill"))}</span><span>${esc(t("events.carnival.level", { level: carnivalSkill.level }))}</span></li>` : ""}
${carnivalTickets ? `<li><span>${esc(t("events.carnival.tickets"))}</span><span>${fmt(carnivalTickets.qty)}</span></li>` : ""}
</ul>
<h4>${esc(t("events.carnival.cooldowns"))}</h4>
<ul class="list-compact">${carnivalCooldowns || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
${diffHtml}
</div>
</div>`;
}
function renderCombat(d) {
const recent = (d.recent_sessions || [])
.map((s) => `<li><span>${esc(s.activity_display_name || s.activity_key)}</span><span>${esc(s.skill_name)}</span></li>`).join("");
@@ -2040,13 +2154,93 @@ function renderCombat(d) {
</table>
</div>
</div>
<div class="card">
<h3>${esc(t("combat.dungeonLastRuns"))}</h3>
<div class="table-wrap">
<table class="combat-table" id="combat-last-runs-table">
<thead><tr>
<th>${esc(t("combat.entry"))}</th>
<th>${esc(t("combat.totalRuns"))}</th>
<th>${esc(t("combat.lastRunKills"))}</th>
<th>${esc(t("combat.foodConsumed"))}</th>
<th>${esc(t("combat.survived"))}</th>
</tr></thead>
<tbody id="combat-last-runs-tbody"></tbody>
</table>
</div>
</div>
<div class="card">
<h3>${esc(t("expeditions.title"))}</h3>
<h4>${esc(t("expeditions.unlocked"))}</h4>
<ul class="list-compact" id="expeditions-unlocked"></ul>
<h4>${esc(t("expeditions.notes"))}</h4>
<ul class="list-compact" id="expeditions-notes"></ul>
<h4>${esc(t("expeditions.pity"))}</h4>
<ul class="list-compact" id="expeditions-pity"></ul>
</div>
<div class="card"><h3>${esc(t("combat.recentActivity"))}</h3><ul class="list-compact">${recent || none}</ul></div>
<div class="card"><h3>${esc(t("combat.activeSessions"))}</h3><ul class="list-compact">${active || none}</ul></div>
</div>`;
renderCombatLastRuns(d);
renderExpeditions(d);
ensureCombatTimeline().then(() => renderCombatBody(d));
}
function renderCombatLastRuns(d) {
const runs = d.combat?.dungeon_runs || {};
const stats = d.dungeon_stats || {};
const keys = [...new Set([...Object.keys(runs), ...Object.keys(stats)])].sort();
const tbody = document.getElementById("combat-last-runs-tbody");
if (!tbody) return;
tbody.innerHTML = keys.length
? keys.map((key) => {
const last = stats[key];
const survived = last
? (last.survived ? t("combat.yes") : t("combat.notSurvived"))
: "—";
return `<tr>
<td>${esc(humanizeKey(key))}</td>
<td>${fmt(runs[key] || 0)}</td>
<td>${last ? fmt(last.kill_count) : "—"}</td>
<td>${last ? fmt(last.food_consumed) : "—"}</td>
<td>${esc(survived)}</td>
</tr>`;
}).join("")
: `<tr><td colspan="5">${esc(t("empty.none"))}</td></tr>`;
}
function renderExpeditions(d) {
const exp = d.expeditions || {};
const none = `<li><span>${esc(t("empty.none"))}</span></li>`;
const unlockedEl = document.getElementById("expeditions-unlocked");
if (unlockedEl) {
const unlocked = (exp.unlocked || []).map((key) =>
`<li><span>${esc(humanizeKey(key))}</span></li>`
).join("");
unlockedEl.innerHTML = unlocked || none;
}
const notesEl = document.getElementById("expeditions-notes");
if (notesEl) {
const notes = Object.entries(exp.notes || {})
.sort((a, b) => b[1] - a[1])
.map(([key, val]) => `<li><span>${esc(humanizeKey(key))}</span><span>${fmt(val)}</span></li>`)
.join("");
notesEl.innerHTML = notes || none;
}
const pityEl = document.getElementById("expeditions-pity");
if (pityEl) {
const pity = Object.entries(exp.pity || {})
.map(([key, val]) => `<li><span>${esc(humanizeKey(key))}</span><span>${fmt(val)}</span></li>`)
.join("");
pityEl.innerHTML = pity || none;
}
}
function renderCombatBody(d) {
const showTrend = combatTrendEnabled();
document.querySelectorAll("#tab-combat .combat-trend-col").forEach((el) => {
@@ -2468,6 +2662,10 @@ async function runDiff() {
delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`,
levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`,
}))}</p>
${(diff.summary?.seasonal_tokens_delta || diff.summary?.tower_floor_delta) ? `<p>${esc(t("history.progressSummary", {
tokens: `${(diff.summary.seasonal_tokens_delta || 0) >= 0 ? "+" : ""}${fmt(diff.summary.seasonal_tokens_delta || 0)}`,
tower: `${(diff.summary.tower_floor_delta || 0) >= 0 ? "+" : ""}${diff.summary.tower_floor_delta || 0}`,
}))}</p>` : ""}
<h4 style="margin-top:16px">${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}</h4>
<table><thead><tr>
<th>${esc(t("inventory.item"))}</th>
@@ -2489,6 +2687,17 @@ function fmt(n) {
return Number(n).toLocaleString(I18n.localeTag());
}
function humanizeKey(key) {
if (key == null || key === "") return "—";
return String(key).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function formatCooldown(ts) {
const ms = normalizeTimeMs(ts);
if (ms == null || ms <= 0 || ms <= Date.now()) return t("events.cooldownReady");
return t("events.cooldownUntil", { time: formatTs(ts) });
}
function formatTs(ts) {
const ms = normalizeTimeMs(ts);
if (ms == null) return "—";
+59 -3
View File
@@ -12,6 +12,7 @@
"equipment": "Ausrüstung",
"quests": "Quests",
"combat": "Kampf",
"events": "Events",
"history": "Verlauf",
"data": "Daten",
"backup": "Backup"
@@ -51,6 +52,8 @@
"questsCompleted": "Story-Quests abgeschlossen",
"slayerKills": "Slayer-Kills",
"dungeonRuns": "Dungeon-Läufe",
"seasonalTokens": "Event-Tokens",
"towerFloor": "Tower-Floor",
"topInventory": "Größte Inventar-Änderungen",
"topSkills": "Größte Skill-Änderungen",
"titleError": "Import-Fehler",
@@ -108,7 +111,18 @@
"pets": "Haustiere",
"farming": "Farming",
"patch": "Patch {n}",
"guildRep": "Gilden-Ruf"
"guildRep": "Gilden-Ruf",
"tower": "Infinite Tower",
"towerFloor": "Aktueller / Bester Floor",
"towerMilestones": "Meilensteine",
"towerBonuses": "Boni",
"worker": "Worker",
"workerSlot": "Slot {slot}",
"titles": "Titel",
"titlesUnlocked": "Freigeschaltet",
"titlesEquipped": "Ausgerüstet",
"town": "Stadt",
"buildingTier": "Stufe {tier}"
},
"skills": {
"search": "Skill suchen…",
@@ -199,7 +213,48 @@
"recentActivity": "Letzte Aktivität",
"activeSessions": "Aktive Sessions",
"sessionDone": "fertig",
"sessionRunning": "läuft"
"sessionRunning": "läuft",
"dungeonLastRuns": "Letzter Dungeon-Lauf",
"lastRunKills": "Kills (letzter Lauf)",
"foodConsumed": "Food",
"survived": "Überlebt",
"notSurvived": "Nein",
"yes": "Ja",
"totalRuns": "Läufe gesamt"
},
"events": {
"seasonal": "Saison-Events",
"noActiveEvent": "Kein aktives Saison-Event",
"tokens": "Event-Tokens",
"minigame": "Minigame",
"banners": "Banner",
"bounties": "Bounties",
"bountyOpen": "Offen",
"bountyProgress": "Fortschritt: {progress}",
"easyMode": "Easy Mode",
"cooldownReady": "Bereit",
"cooldownUntil": "Verfügbar ab {time}",
"noneEquipped": "Keiner",
"carnival": {
"title": "Carnival",
"skill": "Carnival-Skill",
"level": "Level {level}",
"tickets": "Tickets",
"cooldowns": "Minigame-Cooldowns",
"difficulties": "Schwierigkeiten",
"ring_toss": "Ring Toss",
"hammer_strike": "Hammer Strike",
"potion_sequence": "Potion Sequence",
"item_appraisal": "Item Appraisal",
"shell_game": "Shell Game",
"higher_lower": "Higher Lower"
}
},
"expeditions": {
"title": "Expeditionen",
"unlocked": "Freigeschaltet",
"notes": "Skilling-Dungeons",
"pity": "Pity-Counter"
},
"history": {
"loading": "Lade Verlauf…",
@@ -219,7 +274,8 @@
"deleteSnapshot": "Snapshot löschen",
"deleteSnapshotConfirm": "Diesen Snapshot wirklich löschen?",
"deleteSnapshotFailed": "Snapshot konnte nicht gelöscht werden",
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}"
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}",
"progressSummary": "Event-Tokens: {tokens} · Tower-Floor: {tower}"
},
"search": {
"global": "Items, Skills und Ziele suchen…",
+59 -3
View File
@@ -12,6 +12,7 @@
"equipment": "Equipment",
"quests": "Quests",
"combat": "Combat",
"events": "Events",
"history": "History",
"data": "Data",
"backup": "Backup"
@@ -51,6 +52,8 @@
"questsCompleted": "Story quests completed",
"slayerKills": "Slayer kills",
"dungeonRuns": "Dungeon runs",
"seasonalTokens": "Event tokens",
"towerFloor": "Tower floor",
"topInventory": "Largest inventory changes",
"topSkills": "Largest skill changes",
"titleError": "Import errors",
@@ -108,7 +111,18 @@
"pets": "Pets",
"farming": "Farming",
"patch": "Patch {n}",
"guildRep": "Guild reputation"
"guildRep": "Guild reputation",
"tower": "Infinite Tower",
"towerFloor": "Current / best floor",
"towerMilestones": "Milestones",
"towerBonuses": "Bonuses",
"worker": "Worker",
"workerSlot": "Slot {slot}",
"titles": "Titles",
"titlesUnlocked": "Unlocked",
"titlesEquipped": "Equipped",
"town": "Town",
"buildingTier": "Tier {tier}"
},
"skills": {
"search": "Search skills…",
@@ -199,7 +213,48 @@
"recentActivity": "Recent activity",
"activeSessions": "Active sessions",
"sessionDone": "done",
"sessionRunning": "running"
"sessionRunning": "running",
"dungeonLastRuns": "Last dungeon run",
"lastRunKills": "Kills (last run)",
"foodConsumed": "Food",
"survived": "Survived",
"notSurvived": "No",
"yes": "Yes",
"totalRuns": "Total runs"
},
"events": {
"seasonal": "Seasonal events",
"noActiveEvent": "No active seasonal event",
"tokens": "Event tokens",
"minigame": "Minigame",
"banners": "Banners",
"bounties": "Bounties",
"bountyOpen": "Open",
"bountyProgress": "Progress: {progress}",
"easyMode": "Easy mode",
"cooldownReady": "Ready",
"cooldownUntil": "Available from {time}",
"noneEquipped": "None",
"carnival": {
"title": "Carnival",
"skill": "Carnival skill",
"level": "Level {level}",
"tickets": "Tickets",
"cooldowns": "Minigame cooldowns",
"difficulties": "Difficulties",
"ring_toss": "Ring toss",
"hammer_strike": "Hammer strike",
"potion_sequence": "Potion sequence",
"item_appraisal": "Item appraisal",
"shell_game": "Shell game",
"higher_lower": "Higher lower"
}
},
"expeditions": {
"title": "Expeditions",
"unlocked": "Unlocked",
"notes": "Skilling dungeons",
"pity": "Pity counter"
},
"history": {
"loading": "Loading history…",
@@ -219,7 +274,8 @@
"deleteSnapshot": "Delete snapshot",
"deleteSnapshotConfirm": "Really delete this snapshot?",
"deleteSnapshotFailed": "Could not delete snapshot",
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}"
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}",
"progressSummary": "Event tokens: {tokens} · Tower floor: {tower}"
},
"search": {
"global": "Search items, skills and goals…",
+9
View File
@@ -1225,6 +1225,15 @@ body.inv-chart-modal-open {
margin-left: 6px;
font-size: 0.85rem;
}
.event-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
background: var(--accent-dim);
color: var(--accent);
}
tr.has-goal .col-name,
tr.has-goal td:first-child { font-weight: 600; }
.kpi-goals .kpi-value { color: var(--accent); }
+2
View File
@@ -32,6 +32,7 @@
<button class="nav-btn" data-tab="equipment" data-i18n="nav.equipment">Equipment</button>
<button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button>
<button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button>
<button class="nav-btn" data-tab="events" data-i18n="nav.events">Events</button>
<button class="nav-btn" data-tab="history" data-i18n="nav.history">History</button>
<button class="nav-btn" data-tab="backup" data-i18n="nav.data">Data</button>
</nav>
@@ -86,6 +87,7 @@
<section class="tab-panel" id="tab-equipment"></section>
<section class="tab-panel" id="tab-quests"></section>
<section class="tab-panel" id="tab-combat"></section>
<section class="tab-panel" id="tab-events"></section>
<section class="tab-panel" id="tab-history"></section>
<section class="tab-panel" id="tab-backup">
<div class="card backup-tab-card">
+79
View File
@@ -0,0 +1,79 @@
"""Tests for seasonal / tower / carnival save parsing."""
from __future__ import annotations
import unittest
from pathlib import Path
from parser import load_save, normalize_save
class SeasonalParserTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.save_path = Path(__file__).parent / ".tmp" / "fantasyidler_save.json"
if not cls.save_path.is_file():
cls.save_path = Path(__file__).parent / "testfiles" / "fantasyidler_save-3.json"
if not cls.save_path.is_file():
raise unittest.SkipTest("test save not available")
raw, _ = load_save(cls.save_path)
cls.snapshot = normalize_save(raw, source_file=str(cls.save_path))
def test_seasonal_event(self) -> None:
seasonal = self.snapshot["seasonal"]
self.assertIn("tokens_by_event", seasonal)
self.assertIn("active_event_id", seasonal)
self.assertIn("bounty_slots", seasonal)
if seasonal.get("active_event_id"):
event_id = seasonal["active_event_id"]
self.assertIsInstance(seasonal["bounty_slots"], list)
def test_tower(self) -> None:
tower = self.snapshot["tower"]
self.assertIn("current_floor", tower)
self.assertIn("best_floor", tower)
self.assertIn("milestones", tower)
def test_carnival_cooldowns(self) -> None:
carnival = self.snapshot["carnival"]
self.assertIn("cooldowns", carnival)
self.assertIn("ring_toss", carnival["cooldowns"])
def test_workers(self) -> None:
workers = self.snapshot["workers"]
self.assertIsInstance(workers, list)
def test_titles(self) -> None:
titles = self.snapshot["titles"]
self.assertIn("unlocked", titles)
self.assertIn("equipped", titles)
def test_expeditions(self) -> None:
expeditions = self.snapshot["expeditions"]
self.assertIn("unlocked", expeditions)
self.assertIn("notes", expeditions)
self.assertIn("pity", expeditions)
def test_dungeon_stats(self) -> None:
stats = self.snapshot["dungeon_stats"]
self.assertIsInstance(stats, dict)
for entry in stats.values():
self.assertIn("food_consumed", entry)
self.assertIn("kill_count", entry)
self.assertIn("survived", entry)
def test_sunspire_save_values(self) -> None:
if self.snapshot["seasonal"].get("active_event_id") != "sunspire_solstice_2026":
self.skipTest("not sunspire save")
self.assertEqual(
self.snapshot["seasonal"]["tokens_by_event"]["sunspire_solstice_2026"],
31,
)
self.assertEqual(len(self.snapshot["seasonal"]["bounty_slots"]), 3)
self.assertEqual(self.snapshot["tower"]["current_floor"], 39)
self.assertEqual(self.snapshot["workers"][0]["daily_name"], "Dwyn")
self.assertIn("master_angler", self.snapshot["titles"]["unlocked"])
if __name__ == "__main__":
unittest.main()