Compare commits

...
2 Commits
Author SHA1 Message Date
elpatronandCursor 27b23149ce 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>
2026-07-16 11:30:26 +02:00
elpatronandCursor 3b47c16b74 Clarify training advisor table and create goal groups from + button.
Better column labels and legends reduce confusion between per-craft materials,
level-up time, and craft count; the advisor + action now opens a new goal group
with missing material targets plus the main activity goal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 11:30:26 +02:00
8 changed files with 705 additions and 35 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"),
+332 -21
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>` : ""}
@@ -793,6 +835,15 @@ function formatAdvisorMissing(missing) {
.join(", ");
}
function advisorTh(labelKey, hintKey, className = "") {
const label = t(labelKey);
const hint = hintKey ? t(hintKey) : "";
const cls = className ? ` class="${className}"` : "";
return hint
? `<th scope="col"${cls} title="${esc(hint)}">${esc(label)}</th>`
: `<th scope="col"${cls}>${esc(label)}</th>`;
}
function renderSkillAdvisorCard() {
const hint = document.getElementById("skill-advisor-hint");
const body = document.getElementById("skill-advisor-body");
@@ -846,7 +897,8 @@ function renderSkillAdvisorCard() {
const crafts = rec.crafts_to_next_level ?? 0;
const goalLabel = t("skills.advisorAdoptGoalFor", { name: rec.display_name, count: fmt(crafts) });
const goalCell = crafts > 0
? `<button type="button" class="goal-add-btn skill-advisor-goal-btn" data-activity-key="${esc(rec.activity_key)}" data-crafts="${crafts}" title="${esc(goalLabel)}" aria-label="${esc(goalLabel)}">+</button>`
? `<span class="skill-advisor-craft-qty" title="${esc(t("skills.advisorGoalColHint"))}">${esc(fmt(crafts))}×</span>
<button type="button" class="goal-add-btn skill-advisor-goal-btn" data-activity-key="${esc(rec.activity_key)}" data-crafts="${crafts}" title="${esc(goalLabel)}" aria-label="${esc(goalLabel)}">+</button>`
: `<span class="inv-spark-empty">—</span>`;
return `<tr>
<td>${esc(rec.display_name)}</td>
@@ -854,7 +906,7 @@ function renderSkillAdvisorCard() {
<td>${rec.level_required}</td>
<td class="skill-advisor-mats">${esc(mats)}</td>
<td class="num">${esc(eta)}</td>
<td class="col-actions">
<td class="col-actions skill-advisor-goal-cell">
${goalCell}
</td>
</tr>`;
@@ -869,16 +921,17 @@ function renderSkillAdvisorCard() {
<div class="table-wrap">
<table class="skill-advisor-table">
<thead><tr>
<th>${esc(t("skills.advisorActivity"))}</th>
<th>${esc(t("skills.advisorXpMin"))}</th>
<th>${esc(t("skills.advisorReqLevel"))}</th>
<th>${esc(t("skills.advisorMaterials"))}</th>
<th>${esc(t("skills.advisorEtaCol"))}</th>
<th class="col-actions">${esc(t("goals.actions"))}</th>
${advisorTh("skills.advisorActivity")}
${advisorTh("skills.advisorXpMin")}
${advisorTh("skills.advisorReqLevel")}
${advisorTh("skills.advisorMaterials", "skills.advisorMaterialsHint")}
${advisorTh("skills.advisorEtaCol", "skills.advisorEtaColHint")}
${advisorTh("skills.advisorGoalCol", "skills.advisorGoalColHint", "col-actions")}
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
</div>
<p class="skill-advisor-legend">${esc(t("skills.advisorTableLegend"))}</p>`;
body.querySelector(".skill-advisor-skill-goal-btn")?.addEventListener("click", () => {
adoptAdvisorSkillGoal();
@@ -915,30 +968,121 @@ async function adoptAdvisorSkillGoal() {
alert(t("skills.advisorGoalCreated", { name: adv.skill_name, target: targetLevel }));
}
function inventoryQtyMap(data) {
const map = {};
for (const item of data?.inventory || []) {
map[item.key] = item.qty;
}
return map;
}
function collectExistingGoalItemKeys() {
const keys = new Set();
const data = state.goals.data || {};
for (const goal of data.ungrouped || []) {
if (goal.goal_type === "item") keys.add(goal.item_key);
}
for (const group of data.groups || []) {
for (const goal of group.goals || []) {
if (goal.goal_type === "item") keys.add(goal.item_key);
}
}
return keys;
}
function advisorMaterialGoalTargets(rec, crafts, inventoryMap) {
const targets = [];
for (const [key, perCraft] of Object.entries(rec.materials || {})) {
const totalNeeded = Number(perCraft) * crafts;
const have = inventoryMap[key] ?? 0;
const missing = totalNeeded - have;
if (missing > 0) {
targets.push({ item_key: key, target_qty: missing });
}
}
return targets;
}
async function createGoalInGroup(itemKey, targetQty, groupId, existingKeys) {
if (existingKeys.has(itemKey)) {
return { ok: false, skipped: true };
}
const res = await fetch(`${apiBase()}/goals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_key: itemKey,
target_qty: targetQty,
mode: "relative",
group_id: groupId,
}),
});
if (!res.ok) {
return { ok: false, skipped: true };
}
existingKeys.add(itemKey);
return { ok: true, goal: await res.json() };
}
async function adoptAdvisorItemGoal(activityKey, crafts) {
const adv = state.skills.advisor;
if (!adv?.supported || !activityKey) return;
const rec = adv.recommendations?.find((r) => r.activity_key === activityKey);
const name = rec?.display_name || activityKey.replace(/_/g, " ");
const count = Number(crafts);
if (!count || count <= 0) return;
const res = await fetch(`${apiBase()}/goals`, {
if (!count || count <= 0 || !rec) return;
await loadGoals();
const existingKeys = collectExistingGoalItemKeys();
const inventoryMap = inventoryQtyMap(state.data);
const materialTargets = advisorMaterialGoalTargets(rec, count, inventoryMap);
const groupName = t("skills.advisorGoalGroupName", { name });
const groupRes = await fetch(`${apiBase()}/goal-groups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_key: activityKey,
target_qty: count,
mode: "relative",
}),
body: JSON.stringify({ name: groupName }),
});
const result = await res.json();
if (!res.ok) {
alert(result.error || t("goals.createFailed"));
const groupData = await groupRes.json();
if (!groupRes.ok) {
alert(groupData.error || t("goals.groupCreateFailed"));
return;
}
trackEvent("Goal Create", { source: "advisor", type: "item", mode: "relative", group: "none" });
let created = 0;
let skipped = 0;
for (const mat of materialTargets) {
const result = await createGoalInGroup(mat.item_key, mat.target_qty, groupData.id, existingKeys);
if (result.ok) created += 1;
else skipped += 1;
}
const mainResult = await createGoalInGroup(activityKey, count, groupData.id, existingKeys);
if (mainResult.ok) created += 1;
else skipped += 1;
if (created === 0) {
await fetch(`${apiBase()}/goal-groups/${groupData.id}`, { method: "DELETE" });
alert(t("skills.advisorItemGoalsNone"));
return;
}
trackEvent("Goal Group Create", { source: "advisor" });
trackEvent("Goal Create", {
source: "advisor",
type: "item",
mode: "relative",
group: "new",
materials: materialTargets.length,
});
await refreshGoalsAfterCreate();
alert(t("skills.advisorItemGoalCreated", { name, count: fmt(count) }));
let message = t("skills.advisorItemGoalsCreated", { group: groupName, count: fmt(created) });
if (skipped > 0) {
message += ` ${t("skills.advisorItemGoalsSkipped", { count: fmt(skipped) })}`;
}
alert(message);
}
async function refreshGoalsAfterCreate() {
@@ -1902,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("");
@@ -1938,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) => {
@@ -2366,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>
@@ -2387,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 "—";
+72 -7
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…",
@@ -133,14 +147,23 @@
"advisorActivity": "Aktivität",
"advisorXpMin": "XP / Min.",
"advisorReqLevel": "Level",
"advisorMaterials": "Material",
"advisorEtaCol": "Bis Level",
"advisorMaterials": "Material (1×)",
"advisorMaterialsHint": "Materialbedarf für einen einzelnen Craft — nicht für das gesamte Level-Up.",
"advisorEtaCol": "Zeit (~Min.)",
"advisorEtaColHint": "Geschätzte Spielzeit bis zum nächsten Level, wenn du nur diese Aktivität machst.",
"advisorGoalCol": "Ziel (Anz.)",
"advisorGoalColHint": "Anzahl Crafts bis zum nächsten Level. Mit + Zielgruppe inkl. fehlender Materialien anlegen.",
"advisorTableLegend": "Material pro Craft; Zeit und Anzahl beziehen sich auf das komplette Level-Up. Bei 60 Sek. pro Craft können Minuten und Anzahl übereinstimmen.",
"advisorEta": "~{minutes} Min.",
"advisorMissing": "Fehlt: {items}",
"advisorAdoptSkillGoal": "Level {level} als Ziel",
"advisorAdoptGoalFor": "Ziel: {count}× {name} craften (bis nächstes Level)",
"advisorAdoptGoalFor": "Zielgruppe anlegen: {count}× {name} craften plus fehlende Materialien",
"advisorGoalGroupName": "{name} — Level-Up",
"advisorGoalCreated": "Skill-Ziel angelegt: {name} → Level {target}",
"advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften"
"advisorItemGoalCreated": "Item-Ziel angelegt: {count}× {name} craften",
"advisorItemGoalsCreated": "Gruppe \"{group}\" angelegt mit {count} Zielen.",
"advisorItemGoalsSkipped": "{count} Ziele übersprungen (bereits vorhanden).",
"advisorItemGoalsNone": "Keine neuen Ziele angelegt — alle Items haben bereits ein Ziel."
},
"inventory": {
"search": "Item suchen…",
@@ -190,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…",
@@ -210,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…",
+72 -7
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…",
@@ -133,14 +147,23 @@
"advisorActivity": "Activity",
"advisorXpMin": "XP / min",
"advisorReqLevel": "Req. level",
"advisorMaterials": "Materials",
"advisorEtaCol": "Est. to level",
"advisorMaterials": "Materials (×1)",
"advisorMaterialsHint": "Materials needed for a single craft — not for the full level-up.",
"advisorEtaCol": "Time (~min)",
"advisorEtaColHint": "Estimated play time to the next level if you only use this activity.",
"advisorGoalCol": "Goal (qty)",
"advisorGoalColHint": "Number of crafts to reach the next level. Use + to create a goal group including missing materials.",
"advisorTableLegend": "Materials are per craft; time and quantity refer to the full level-up. At 60 seconds per craft, minutes and quantity may match.",
"advisorEta": "~{minutes} min",
"advisorMissing": "Missing: {items}",
"advisorAdoptSkillGoal": "Level {level} as goal",
"advisorAdoptGoalFor": "Goal: craft {count}× {name} (to next level)",
"advisorAdoptGoalFor": "Create goal group: craft {count}× {name} plus missing materials",
"advisorGoalGroupName": "{name} — level-up",
"advisorGoalCreated": "Skill goal created: {name} → level {target}",
"advisorItemGoalCreated": "Item goal created: craft {count}× {name}"
"advisorItemGoalCreated": "Item goal created: craft {count}× {name}",
"advisorItemGoalsCreated": "Created group \"{group}\" with {count} goals.",
"advisorItemGoalsSkipped": "{count} goals skipped (already exist).",
"advisorItemGoalsNone": "No new goals created — all items already have a goal."
},
"inventory": {
"search": "Search items…",
@@ -190,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…",
@@ -210,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…",
+30
View File
@@ -393,6 +393,27 @@ tr:hover td { background: var(--bg-hover); }
max-width: 16rem;
}
.skill-advisor-legend {
margin: 10px 0 0;
color: var(--text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.skill-advisor-goal-cell {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
.skill-advisor-craft-qty {
font-size: 0.85rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.skill-advisor-summary-row {
display: flex;
flex-wrap: wrap;
@@ -1204,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()