Compare commits
2
Commits
cbe6174e7c
...
27b23149ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b23149ce | ||
|
|
3b47c16b74 |
@@ -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"),
|
||||
|
||||
+332
-21
@@ -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
@@ -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
@@ -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…",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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