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:
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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…",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user