"use strict"; // Quests tab: quests (sp2_quest_info.ini), rewards (sp2_quest_present.ini), daily / weekly / // monthly missions and a check of all of them, read from the game's packs by src/main/quests.js. // Edits are kept as a draft per section (raw INI values + player-visible texts) and saved as one // change: only the changed keys are written, texts go to the string table as STR(n). // Key meanings: docs/quests.md and src/main/questspec.js. (() => { const Q = { src: null, text: null, data: null, spec: null, sub: "quests", loading: false, draft: null, detail: null, j: 1, presents: null, pick: null, mKind: "daily", missions: {}, mDraft: null, rDraft: null }; window.QST = Q; // (dev probes) const nf = (v) => Number(v || 0).toLocaleString(); const num = (v) => { const n = parseInt(String(v ?? "").trim(), 10); return Number.isFinite(n) ? n : 0; }; // ---------- icons ---------- function iconCanvas(name, px) { const c = el("canvas", { class: "it-icon", width: px, height: px }); c.style.width = `${px}px`; c.style.height = `${px}px`; if (name) drawIcon(c, name, px); return c; } async function drawIcon(c, name, px) { const i = name.indexOf("#"); const im = i > 0 ? await UIRes.image(name.slice(0, i), name.slice(i + 1)) : null; if (!im?.bmp) { c.classList.add("missing"); c.title = `${name} (not found)`; return; } const ctx = c.getContext("2d"); const s = Math.min(px / im.w, px / im.h); const w = Math.round(im.w * s); const h = Math.round(im.h * s); ctx.imageSmoothingQuality = "high"; ctx.drawImage(im.bmp, im.x, im.y, im.w, im.h, (px - w) / 2, (px - h) / 2, w, h); c.title = name; } const runsEl = (runs) => runs.map((r) => el("span", { text: `${" ".repeat(r.space || 0)}${r.text}`, style: r.color ? `color:${r.color}` : "" })); // ---------- drafts: one INI section being edited ---------- // values: lower key → { key (as written), raw }; texts: lower key → text (player-visible) class Draft { constructor(file, section, data, { create = false } = {}) { this.file = file; this.section = section; this.create = create; this.orig = new Map(); this.origTexts = {}; this.values = new Map(); this.texts = {}; for (const [k, v] of data?.entries || []) { this.orig.set(k.toLowerCase(), { key: k, raw: v }); this.values.set(k.toLowerCase(), { key: k, raw: v }); } Object.assign(this.origTexts, data?.texts || {}); Object.assign(this.texts, data?.texts || {}); this.onChange = () => {}; } get(k) { return this.values.get(k.toLowerCase())?.raw; } has(k) { return this.values.has(k.toLowerCase()); } set(k, raw) { const lk = k.toLowerCase(); if (raw === null || raw === undefined || raw === "") this.values.delete(lk); else this.values.set(lk, { key: this.values.get(lk)?.key || this.orig.get(lk)?.key || k, raw: String(raw) }); this.onChange(); } text(k) { const lk = k.toLowerCase(); return lk in this.texts ? this.texts[lk] : this.get(k) || ""; } setText(k, t) { this.texts[k.toLowerCase()] = t; if (!this.has(k) && t) this.set(k, "STR(0)"); // placeholder: the main process gives it a number if (!t) this.set(k, null); this.onChange(); } keys(prefix) { return [...this.values.values()].filter((v) => v.key.toLowerCase().startsWith(prefix.toLowerCase())); } dirty() { return this.create || !!this.change(); } /** → { file, section, create, values, texts } | null when nothing changed */ change() { const values = {}; const texts = {}; for (const [lk, v] of this.values) { if (lk in this.texts) continue; if (this.orig.get(lk)?.raw !== v.raw || this.create) values[v.key] = v.raw; } for (const [lk, v] of this.orig) if (!this.values.has(lk) && !(lk in this.texts)) values[v.key] = null; for (const [lk, t] of Object.entries(this.texts)) { const key = this.values.get(lk)?.key || this.orig.get(lk)?.key || lk; if (t !== this.origTexts[lk] || this.create) { texts[key] = t; // a copied STR(n) stays as the key's value: the main process writes the text under this section const raw = this.values.get(lk)?.raw; if (raw && raw !== "STR(0)" && this.create) values[key] = raw; } } for (const k of Object.keys(values)) if (values[k] === "STR(0)") delete values[k]; if (!this.create && !Object.keys(values).length && !Object.keys(texts).length) return null; return { file: this.file, section: this.section, create: this.create, values, texts }; } } // ---------- form fields bound to a draft ---------- function row(label, control, hint) { return el("div", { class: "q-field" }, [el("label", { text: label, title: hint || "" }), control]); } function fText(d, key, label, { multiline = false, hint } = {}) { const input = multiline ? el("textarea", { rows: 3, spellcheck: "false" }) : el("input", { type: "text", spellcheck: "false" }); input.value = d.text(key); input.addEventListener("input", () => d.setText(key, input.value)); return row(label, input, hint || key); } function fRaw(d, key, label, { placeholder = "", hint, onInput } = {}) { const input = el("input", { type: "text", spellcheck: "false", placeholder: String(placeholder) }); input.value = d.get(key) ?? ""; input.addEventListener("input", () => { d.set(key, input.value.trim()); onInput?.(input.value.trim()); }); return row(label, input, hint || key); } function fBool(d, key, label, def = 0) { const cb = el("input", { type: "checkbox" }); cb.checked = d.has(key) ? num(d.get(key)) !== 0 : !!def; cb.addEventListener("change", () => d.set(key, cb.checked ? "1" : "0")); return el("label", { class: "chip q-chip", title: key }, [cb, el("span", { text: label })]); } function fEnum(d, key, label, map, def, { onChange } = {}) { const sel = el("select"); const cur = d.has(key) ? String(num(d.get(key))) : String(def); const keys = Object.keys(map); if (!keys.includes(cur)) sel.append(el("option", { value: cur, text: `${cur} (unknown)` })); for (const k of keys.sort((a, b) => Number(a) - Number(b))) sel.append(el("option", { value: k, text: `${k} · ${map[k]}` })); sel.value = cur; sel.addEventListener("change", () => { d.set(key, sel.value); onChange?.(sel.value); }); return row(label, sel, key); } // ---------- loading ---------- async function load({ reload = false } = {}) { if (Q.loading) return; if (!Q.src) Q.src = profileSource(); if (!Q.src) { $("q-source").textContent = "This profile has no game folder: set one in the profile, or pick a folder."; return; } Q.loading = true; $("q-source").textContent = `Reading ${Q.src.folder}…`; try { if (!Q.spec) Q.spec = await call("q:spec"); const [data] = await Promise.all([call("q:load", Q.src, { textFile: Q.text, reload }), UIRes.load(Q.src)]); Q.data = data; Q.text = data.text; Q.presents = null; Q.missions = {}; $("q-text").replaceChildren(...data.texts.map((t) => el("option", { value: t.file, text: t.label, selected: t.file === data.text }))); const errs = data.quests.filter((q) => q.errors).length; $("q-source").textContent = `${Q.src.folder} · ${nf(data.quests.length)} quests${errs ? ` · ${errs} with errors` : ""}${data.missing.length ? ` · missing: ${data.missing.join(", ")}` : ""}`; fillClassFilter(); showQuests(); if (Q.sub === "rewards") await showRewards(); if (Q.sub === "missions") await showMissions(); if (Q.sub === "checks") await runChecks(); } catch (err) { $("q-source").textContent = err.message; toast(err.message, true); } finally { Q.loading = false; } } async function refreshAfterSave(select) { Q.data = await call("q:load", Q.src, { textFile: Q.text }); Q.presents = null; Q.missions = {}; showQuests({ keep: true }); if (select) openQuest(select); } async function save(changes, { what = "Saved" } = {}) { try { const r = await call("q:save", Q.src, Q.text, changes); toast(`${what}: ${r.written.join(", ") || "nothing to write"}`); for (const w of r.warnings) toast(w, true); return true; } catch (err) { toast(err.message, true); return false; } } // ---------- quest list ---------- const flags = (q) => { const f = []; if (q.subs.some((s) => s.perform === 2)) f.push("event"); if (q.subs.some((s) => s.oneday)) f.push("daily"); if (q.subs.some((s) => s.repeat)) f.push("repeat"); return f; }; function qTable() { if (Q.table) return Q.table; Q.table = new VTable( $("q-table"), [ { key: "icon", label: "", width: 40, format: (r) => iconCanvas(r.subs[0]?.icon, 30) }, { key: "main", label: "Main", width: 70, cls: "num mono" }, { key: "title", label: "Title", grow: true, width: 220, format: (r) => r.subs[0]?.title || "(no title)", sort: (r) => r.subs[0]?.title || "￿", title: true }, { key: "cls", label: "Class", width: 170, format: (r) => r.cls.replace(/^Quest/, ""), title: true }, { key: "subs", label: "Subs", width: 50, cls: "num", format: (r) => String(r.subs.length), sort: (r) => r.subs.length }, { key: "flags", label: "", width: 90, format: (r) => el("span", { class: "q-flags" }, [...flags(r).map((f) => el("b", { class: `q-flag ${f}`, text: f[0].toUpperCase(), title: f })), r.errors ? el("b", { class: "q-flag bad", text: "!", title: `${r.errors} error(s)` }) : r.warnings ? el("b", { class: "q-flag warn", text: "?", title: `${r.warnings} warning(s)` }) : null]), sort: (r) => r.errors * 1000 + r.warnings, }, ], { multi: false, rowHeight: 36, id: "quests" }, ); Q.table.keyOf = (r) => r.section; Q.table.onSelect = (_s, r) => r && openQuest(r.section); return Q.table; } function fillClassFilter() { const counts = {}; for (const q of Q.data.quests) counts[q.cls] = (counts[q.cls] || 0) + 1; const cur = $("q-class").value; $("q-class").replaceChildren(el("option", { value: "", text: "All classes" }), ...Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([c, n]) => el("option", { value: c, text: `${c.replace(/^Quest/, "")} (${n})` }))); $("q-class").value = cur; } function showQuests({ keep = false } = {}) { const t = qTable(); t.setRows(Q.data.quests, { keepSelection: keep }); filterQuests(); } function filterQuests() { const q = $("q-search").value.trim().toLowerCase(); const cls = $("q-class").value; const f = $("q-filter").value; const now = new Date().toISOString().slice(0, 16).replace("T", " "); qTable().setView((r) => { if (cls && r.cls !== cls) return false; if (f === "event" && !r.subs.some((s) => s.perform === 2)) return false; if (f === "active" && !r.subs.some((s) => s.perform === 2 && s.start && s.end && s.start <= now && now < s.end)) return false; if (f === "daily" && !r.subs.some((s) => s.oneday)) return false; if (f === "repeat" && !r.subs.some((s) => s.repeat)) return false; if (f === "problems" && !r.errors && !r.warnings) return false; if (!q) return true; return String(r.main) === q || r.section.toLowerCase().includes(q) || r.cls.toLowerCase().includes(q) || r.subs.some((s) => (s.title || "").toLowerCase().includes(q)); }); $("q-count").textContent = `${nf(qTable().view.length)} of ${nf(qTable().rows.length)}`; } // ---------- quest editor ---------- async function leaveDraft() { if (!Q.draft?.dirty()) return true; return confirmBox("Unsaved changes", `[${Q.draft.section}] has changes that aren't saved. Drop them?`, "Drop changes", "danger"); } async function openQuest(section, { j = 1 } = {}) { if (Q.draft && Q.draft.section !== section && !(await leaveDraft())) return; let d; try { d = await call("q:quest", Q.src, Q.text, section); } catch (err) { return toast(err.message, true); } if (!d) return toast(`[${section}] isn't in the quest file.`, true); Q.detail = d; Q.draft = new Draft("quest", d.section, d); Q.j = Math.min(j, Math.max(1, num(Q.draft.get("max_sub_quest")))); renderQuest(); } const clsSpec = (name) => Q.spec.classes.find((c) => c.name.toLowerCase() === String(name || "").toLowerCase()) || null; function renderQuest() { const d = Q.draft; const det = Q.detail || { subs: [], problems: [] }; const box = $("q-detail"); const maxSub = num(d.get("max_sub_quest")); const j = Q.j; const P = (k) => `sub${j}_${k}`; const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveQuest() }); d.onChange = () => (saveBtn.disabled = !d.dirty()); const cls = clsSpec(d.get("class_name")); const clsSel = el("select"); const names = Q.spec.classes.map((c) => c.name); if (d.get("class_name") && !names.some((n) => n.toLowerCase() === d.get("class_name").toLowerCase())) names.unshift(d.get("class_name")); for (const n of names) clsSel.append(el("option", { value: n, text: `${n.replace(/^Quest/, "")} — ${clsSpec(n)?.desc || "unknown class"}` })); clsSel.value = names.find((n) => n.toLowerCase() === String(d.get("class_name") || "").toLowerCase()) || ""; clsSel.addEventListener("change", () => { d.set("class_name", clsSel.value); renderQuest(); }); const subPick = maxSub > 12 ? el("select", { onchange: (e) => ((Q.j = Number(e.target.value)), renderQuest()) }, Array.from({ length: maxSub }, (_, i) => el("option", { value: i + 1, text: `Sub ${i + 1}: ${d.text(`sub${i + 1}_title`) || ""}`, selected: i + 1 === j }))) : el("nav", { class: "seg q-subs" }, Array.from({ length: maxSub }, (_, i) => el("button", { class: i + 1 === j ? "on" : "", text: String(i + 1), title: d.text(`sub${i + 1}_title`), onclick: () => ((Q.j = i + 1), renderQuest()) }))); const subDet = det.subs?.[j - 1] || {}; const problems = (det.problems || []).filter((p) => !p.sub || p.sub === j); box.replaceChildren( ...[ el("div", { class: "it-head q-head" }, [ iconCanvas(d.get(P("icon")), 56), el("div", { class: "q-head-text" }, [ el("h3", { text: d.text(P("title")) || "(no title)" }), el("div", { class: "muted", text: `[${d.section}] · main ${d.get("main_index") || "?"} · code ${num(d.get("main_index")) * 10000 + j}${d.create ? " · new, not saved yet" : ""}` }), ]), el("div", { class: "q-head-actions" }, [ saveBtn, el("button", { class: "iconbtn only", title: "Revert", onclick: () => (d.create ? closeQuest() : openQuest(d.section, { j })) }, [icon("refresh")]), el("button", { class: "iconbtn only", title: "Duplicate as a new quest", disabled: d.create, onclick: () => duplicateQuest() }, [icon("copy")]), el("button", { class: "iconbtn only danger", title: "Delete this quest", disabled: d.create, onclick: () => deleteQuest() }, [icon("trash")]), ]), ]), problems.length ? el("div", { class: "q-problems" }, problems.map((p) => el("div", { class: `q-problem ${p.sev}`, text: `${p.sub ? `Sub ${p.sub}: ` : ""}${p.msg}` }))) : null, card("Quest", [ row("Class", clsSel, "class_name — one class for every sub of the quest"), cls ? el("small", { class: "muted q-note", text: cls.desc }) : null, fRaw(d, "main_index", "Main index", { hint: "main_index: the quest id (1–65535), unique; its subs are one chain", onInput: () => renderHeadOnly() }), row( "Sub quests", el("div", { class: "q-inline" }, [ subPick, el("button", { class: "iconbtn", title: "Add a sub quest after the last one (a copy of it, chained to it)", onclick: () => addSub() }, [icon("plus"), el("span", { text: "Add" })]), el("button", { class: "iconbtn danger", title: "Remove the last sub quest", disabled: maxSub <= 1, onclick: () => removeSub() }, [icon("trash"), el("span", { text: "Remove last" })]), ]), ), ]), card(`Sub ${j}: texts`, [ fText(d, P("title"), "Title"), fText(d, P("progress"), "Progress text", { multiline: true }), fText(d, P("progress_result"), "Counter suffix", { hint: "progress_result: shown after cur/max (e.g. 分鐘); empty = no counter" }), iconField(d, P("icon"), "Icon"), iconField(d, P("sub_icon"), "Small icon"), ]), goalCard(d, j, cls), rewardsCard(d, j, subDet), card("When it's offered", [ fEnum(d, P("occur_value"), "Offered on", Q.spec.enums.occur, 0), fRaw(d, P("occur_value_ex"), "Etc item type (occur 20)", { placeholder: -1 }), fEnum(d, P("occur_mode"), "Only in mode", Q.spec.enums.mode, -1), fEnum(d, P("occur_excepting_mode"), "Not in mode", Q.spec.enums.mode, -1), fEnum(d, P("occur_room_style"), "Room", Q.spec.enums.room, -1), fRaw(d, P("occur_limit_grade_min"), "From grade", { placeholder: -1 }), fRaw(d, P("occur_limit_grade_max"), "Up to grade", { placeholder: -1 }), chainRow(d, j, "prev", "Needs quest", subDet.prev), fEnum(d, P("channeling_type"), "Channeling", Q.spec.enums.channeling, -1), ]), timingCard(d, j), card("After completion / options", [ chainRow(d, j, "next", "Then offer", subDet.next), el("div", { class: "q-chips" }, [ fBool(d, P("rewardselect_style"), "Player picks rewards"), fBool(d, P("guild_style"), "Needs a guild"), fBool(d, P("pcroom_style"), "PC room only"), fBool(d, P("invited_pass"), "Auto-pass invites"), fBool(d, P("user_mode_pass"), "Not in custom rooms"), fBool(d, P("death_time_pass"), "Not while dead"), fBool(d, P("complete_game_alarm"), "Announce completion"), fBool(d, P("complete_web_alarm"), "Web board"), fBool(d, P("costume"), "Costume mark"), fBool(d, P("play_occur_show"), "Popup: offered (play)", 1), fBool(d, P("play_complete_show"), "Popup: done (play)", 1), fBool(d, P("round_end_occur_show"), "Popup: offered (round end)", 1), fBool(d, P("round_end_complete_show"), "Popup: done (round end)", 1), ]), fRaw(d, P("rewardselect_num"), "Rewards to pick", { placeholder: 1 }), fRaw(d, P("guild_terms_level"), "Min guild level", { placeholder: 0 }), ]), helpCard(d, j, subDet), subDet.guides?.length ? card( "Guides", subDet.guides.map((g) => el("div", { class: "q-guide mono", text: `[${g.section}] ${g.data.join(" → ")}` })), ) : null, rawCard(d, `sub${j}_`, `All keys of sub ${j}`), rawCard(d, "", "All keys of the quest", { exclude: /^sub\d+_/i }), ].filter(Boolean), ); } function renderHeadOnly() { const m = $("q-detail").querySelector(".q-head .muted"); if (m && Q.draft) m.textContent = `[${Q.draft.section}] · main ${Q.draft.get("main_index") || "?"} · code ${num(Q.draft.get("main_index")) * 10000 + Q.j}${Q.draft.create ? " · new, not saved yet" : ""}`; } function card(title, children, { open = true } = {}) { return el("details", { class: "q-card", open }, [el("summary", { text: title }), el("div", { class: "q-card-body" }, children.filter(Boolean))]); } function iconField(d, key, label) { const holder = el("span", { class: "q-icon-prev" }, [iconCanvas(d.get(key), 28)]); const input = el("input", { type: "text", spellcheck: "false", placeholder: "Set#Image" }); input.value = d.get(key) || ""; let t = 0; input.addEventListener("input", () => { d.set(key, input.value.trim()); clearTimeout(t); t = setTimeout(() => holder.replaceChildren(iconCanvas(input.value.trim(), 28)), 300); }); return row(label, el("div", { class: "q-inline" }, [holder, input]), key); } function goalCard(d, j, cls) { const P = (k) => `sub${j}_${k}`; const labels = cls?.custom || []; // how many custom values: the ones there (contiguous), at least the class's labels let n = 0; while (d.has(P(`custom_value${n + 1}`))) n++; const list = cls?.list ? num(d.get(P(`custom_value${cls.list}`))) : 0; const want = Math.max(n, labels.length, cls?.list ? cls.list + list : 0); const rows = []; for (let i = 1; i <= want; i++) { let label = labels[i - 1] || (cls?.list && i > cls.list ? `Code ${i - cls.list}` : `Value ${i}`); if (cls?.target === i) label += " (target)"; rows.push(fRaw(d, P(`custom_value${i}`), label, { placeholder: "0", hint: `custom_value${i}`, onInput: () => {} })); } return card("Goal", [ fEnum(d, P("complete_value"), "Completes on", Q.spec.enums.complete, 0, { onChange: () => {} }), cls && cls.qc ? el("small", { class: "muted q-note", text: `This class normally uses ${cls.qc} (${Q.spec.enums.complete[cls.qc]}). Client and server must have the same value.` }) : null, ...rows, el("div", { class: "q-inline" }, [ el("button", { class: "iconbtn", title: "Add the next custom value (they must stay contiguous)", onclick: () => (d.set(P(`custom_value${want + 1}`), "0"), renderQuest()) }, [icon("plus"), el("span", { text: "Add value" })]), el("button", { class: "iconbtn", disabled: !want, title: "Remove the last custom value", onclick: () => (d.set(P(`custom_value${want}`), null), renderQuest()) }, [icon("trash"), el("span", { text: "Remove last" })]), ]), ]); } function rewardsCard(d, j) { const P = (k) => `sub${j}_${k}`; const max = Math.min(num(d.get(P("max_reward"))), 5); const rows = []; for (let k = 1; k <= max; k++) { const idx = num(d.get(P(`reward_present${k}`))); const info = el("span", { class: "q-reward-info" }); fillReward(info, idx); const input = el("input", { type: "text", value: idx ? String(idx) : "", class: "q-num", spellcheck: "false" }); input.addEventListener("change", () => { d.set(P(`reward_present${k}`), input.value.trim()); fillReward(info, num(input.value)); }); rows.push( el("div", { class: "q-reward" }, [ el("span", { class: "muted", text: `#${k}` }), input, info, el("button", { class: "iconbtn", title: "Pick a reward", onclick: async () => { const r = await pickReward(); if (r) { input.value = String(r); d.set(P(`reward_present${k}`), String(r)); fillReward(info, r); } }, }, [icon("search"), el("span", { text: "Pick" })]), el("button", { class: "iconbtn only danger", title: "Remove this reward", onclick: () => removeReward(d, j, k) }, [icon("trash")]), ]), ); } return card(`Rewards (${max} of 5)`, [ ...rows, el("div", { class: "q-inline" }, [ el("button", { class: "iconbtn", disabled: max >= 5, onclick: async () => { const r = await pickReward(); if (!r) return; d.set(P(`reward_present${max + 1}`), String(r)); d.set(P("max_reward"), String(max + 1)); renderQuest(); }, }, [icon("plus"), el("span", { text: "Add reward" })]), el("span", { class: "muted", text: "Rewards are sp2_quest_present.ini entries (Rewards tab); the server gives them." }), ]), ]); } function removeReward(d, j, k) { const P = (x) => `sub${j}_${x}`; const max = Math.min(num(d.get(P("max_reward"))), 5); for (let i = k; i < max; i++) d.set(P(`reward_present${i}`), d.get(P(`reward_present${i + 1}`))); d.set(P(`reward_present${max}`), null); d.set(P("max_reward"), String(max - 1)); renderQuest(); } async function presentsList() { if (!Q.presents) Q.presents = await call("q:presents", Q.src, Q.text); return Q.presents; } async function fillReward(node, idx) { if (!idx) return node.replaceChildren(el("span", { class: "muted", text: "(none)" })); const p = (await presentsList()).find((x) => x.index === idx); if (!p) return node.replaceChildren(el("span", { class: "q-bad", text: `No reward ${idx}` })); node.replaceChildren(iconCanvas(p.icon, 24), el("span", { text: p.label, title: `${p.typeName} · ${p.section} · value ${p.v1} / ${p.v2}${p.direct ? " · direct" : ""}` })); } function chainRow(d, j, which, label, info) { const P = (k) => `sub${j}_${which}_${k}`; const main = el("input", { type: "text", class: "q-num", placeholder: "main", value: d.get(P("main_index")) || "" }); const sub = el("input", { type: "text", class: "q-num", placeholder: "sub", value: d.get(P("sub_index")) || "" }); main.addEventListener("input", () => d.set(P("main_index"), main.value.trim())); sub.addEventListener("input", () => d.set(P("sub_index"), sub.value.trim())); const link = info?.section ? el("a", { class: "q-link", href: "#", text: `${info.title || "(no title)"} [${info.section}]`, onclick: (e) => (e.preventDefault(), openQuest(info.section, { j: info.sub })) }) : info ? el("span", { class: "q-bad", text: "doesn't exist" }) : null; return row(label, el("div", { class: "q-inline" }, [main, el("span", { class: "muted", text: "." }), sub, link]), `${which}_main_index / ${which}_sub_index`); } function timingCard(d, j) { const P = (k) => `sub${j}_${k}`; const isEvent = num(d.get(P("perform_type")) || 0) === 2; const dateRow = (which, label) => { const y = num(d.get(P(`${which}_year`))); const val = y ? `${y}-${String(num(d.get(P(`${which}_month`)))).padStart(2, "0")}-${String(num(d.get(P(`${which}_date`)))).padStart(2, "0")}` : ""; const date = el("input", { type: "date", value: val }); const hour = el("input", { type: "number", min: 0, max: 23, value: d.get(P(`${which}_hour`)) ?? "" }); const put = () => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date.value); d.set(P(`${which}_year`), m ? String(Number(m[1])) : null); d.set(P(`${which}_month`), m ? String(Number(m[2])) : null); d.set(P(`${which}_date`), m ? String(Number(m[3])) : null); d.set(P(`${which}_hour`), m ? String(num(hour.value)) : null); }; date.addEventListener("change", put); hour.addEventListener("change", put); return row(label, el("div", { class: "q-inline" }, [date, hour, el("span", { class: "muted", text: ":00" })]), `${which}_year/month/date/hour`); }; return card("Type and timing", [ fEnum(d, P("perform_type"), "Type", Q.spec.enums.perform, 1, { onChange: () => renderQuest() }), isEvent ? dateRow("start", "Starts") : null, isEvent ? dateRow("end", "Ends") : null, isEvent ? el("small", { class: "muted q-note", text: "Only the server reads the dates: it turns the event on and off." }) : null, el("div", { class: "q-chips" }, [fBool(d, P("oneday_style"), "Daily (reset every day)"), fBool(d, P("repeat_style"), "Repeatable")]), ]); } function helpCard(d, j, subDet) { const P = (k) => `sub${j}_${k}`; const helps = subDet.help || []; return card("Help texts", [ fRaw(d, P("help"), "Help index", { placeholder: 0, hint: "help: H = in progress, H+100000 = completed, H+200000 = objective (sp2_quest_help.ini)" }), ...helps.map((h) => el("div", { class: "q-help" }, [ el("div", { class: "q-help-head" }, [ el("b", { text: `${h.role} · ${h.index}` }), h.section ? el("span", { class: "muted", text: ` [${h.section}]` }) : el("span", { class: "muted", text: " (none)" }), h.section ? el("button", { class: "iconbtn", onclick: () => editHelp(h.section) }, [icon("edit"), el("span", { text: "Edit texts" })]) : null, ]), h.lines.length ? el("div", { class: "q-help-body" }, h.lines.map((l) => el("div", { class: "it-mline" }, runsEl(l.runs)))) : null, ]), ), el("div", { class: "q-inline" }, [ el("button", { class: "iconbtn", disabled: d.create, title: "Copy these help texts to a new help index and point this sub quest at it", onclick: () => newHelp(d, j, helps), }, [icon("plus"), el("span", { text: helps.some((h) => h.section) ? "New help (copy of these)" : "New help" })]), ]), ]); } /** Raw key/value grid of the draft's keys under a prefix (every key, also those the form doesn't show). */ function rawCard(d, prefix, title, { exclude } = {}) { const body = el("div", { class: "q-raw" }); const draw = () => { const rows = d.keys(prefix).filter((v) => !exclude || !exclude.test(v.key)); body.replaceChildren( ...rows.map((v) => { const lk = v.key.toLowerCase(); const isText = lk in d.texts; const input = el("input", { type: "text", spellcheck: "false", value: isText ? d.texts[lk] : v.raw }); input.addEventListener("change", () => (isText ? d.setText(v.key, input.value) : d.set(v.key, input.value.trim()))); return el("div", { class: "q-raw-row" }, [el("code", { text: v.key, title: isText ? `${v.raw} (text)` : "" }), input, el("button", { class: "iconbtn only danger", title: "Remove the key", onclick: () => (d.set(v.key, null), draw()) }, [icon("trash")])]); }), el("div", { class: "q-raw-row" }, [ el("button", { class: "iconbtn", onclick: async () => { const k = await prompt("Add a key", `Key name${prefix ? ` (starts with ${prefix})` : ""}`, prefix); if (!k) return; d.set(k, "0"); draw(); }, }, [icon("plus"), el("span", { text: "Add key" })]), ]), ); }; draw(); return card(title, [body], { open: false }); } // ---------- quest actions ---------- async function saveQuest() { const d = Q.draft; const change = d.change(); if (!change) return; if (!change.values.class_name && d.create) return toast("Pick a class first.", true); if (await save([change], { what: d.create ? `Created [${d.section}]` : `Saved [${d.section}]` })) { Q.draft = null; await refreshAfterSave(d.section); } } function closeQuest() { Q.draft = null; $("q-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a quest." })); } function newQuestDraft(section, main, cls, title) { const d = new Draft("quest", section, null, { create: true }); const c = clsSpec(cls); d.set("class_name", cls); d.set("main_index", String(main)); d.set("max_sub_quest", "1"); d.set("sub1_perform_type", "1"); d.set("sub1_channeling_type", "-1"); d.set("sub1_occur_value", "1"); d.set("sub1_complete_value", String(c?.qc || 0)); d.set("sub1_max_reward", "0"); d.set("sub1_help", "0"); d.set("sub1_period_hour", "0"); (c?.custom || []).forEach((label, i) => d.set(`sub1_custom_value${i + 1}`, /\(-1/.test(label) ? "-1" : "0")); d.setText("sub1_title", title); return d; } async function newQuest() { if (!(await leaveDraft())) return; const next = await call("q:next", Q.src, Q.text); const clsSel = el("select", {}, Q.spec.classes.map((c) => el("option", { value: c.name, text: `${c.name.replace(/^Quest/, "")} — ${c.desc}` }))); clsSel.value = "QuestBasic"; const title = el("input", { type: "text", spellcheck: "false", placeholder: "Title the players see" }); const main = el("input", { type: "text", value: String(next.main) }); const ok = await modal( "New quest", [el("label", { text: "Class (what the player has to do)" }), clsSel, el("label", { text: "Title" }), title, el("label", { text: `Main index (free: ${next.main})` }), main, el("p", { class: "muted", text: `Goes into [${next.questSection}] at the end of sp2_quest_info.ini. Nothing is written until you press Create.` })], [{ label: "Cancel", value: false }, { label: "Next", kind: "primary", value: true }], ); if (!ok) return; if (Q.data.quests.some((q) => q.main === num(main.value))) return toast(`Main index ${main.value} is already used.`, true); Q.detail = { subs: [{}], problems: [] }; Q.draft = newQuestDraft(next.questSection, num(main.value), clsSel.value, title.value.trim()); Q.j = 1; qTable().clearSelection(); renderQuest(); } async function duplicateQuest() { if (!(await leaveDraft())) return; const src = Q.draft; const next = await call("q:next", Q.src, Q.text); const d = new Draft("quest", next.questSection, null, { create: true }); for (const v of src.values.values()) d.values.set(v.key.toLowerCase(), { ...v }); for (const [k, t] of Object.entries(src.texts)) d.texts[k] = t; d.set("main_index", String(next.main)); // the copy's own chain: links inside the old main index move to the new one const oldMain = src.get("main_index"); for (const v of d.values.values()) if (/_(prev|next)_main_index$/i.test(v.key) && v.raw === oldMain) v.raw = String(next.main); Q.detail = { subs: Q.detail.subs, problems: [] }; Q.draft = d; qTable().clearSelection(); renderQuest(); toast(`Copy made as [${d.section}] (main ${next.main}). Press Create to write it.`); } async function deleteQuest() { const d = Q.draft; const ok = await confirmBox( "Delete quest", `Delete [${d.section}] (main ${d.get("main_index")}) from sp2_quest_info.ini? Players' progress on it is removed at their next login once the server has the new file. Its texts stay in the string table.`, "Delete", "danger", ); if (!ok) return; if (await save([{ file: "quest", section: d.section, remove: true }], { what: `Deleted [${d.section}]` })) { closeQuest(); await refreshAfterSave(null); } } function addSub() { const d = Q.draft; const max = num(d.get("max_sub_quest")); const from = `sub${max}_`; const to = `sub${max + 1}_`; for (const v of d.keys(from)) { const k = to + v.key.slice(from.length); if (v.key.toLowerCase() in d.texts) d.texts[k.toLowerCase()] = d.texts[v.key.toLowerCase()]; d.values.set(k.toLowerCase(), { key: k, raw: /STR\(/i.test(v.raw) ? "STR(0)" : v.raw }); } // chained: the new sub needs the previous one d.set(`${to}prev_main_index`, d.get("main_index")); d.set(`${to}prev_sub_index`, String(max)); d.set(`${from}next_main_index`, d.get("main_index")); d.set(`${from}next_sub_index`, String(max + 1)); d.set(`${to}next_main_index`, null); d.set(`${to}next_sub_index`, null); d.set("max_sub_quest", String(max + 1)); Q.j = max + 1; renderQuest(); } async function removeSub() { const d = Q.draft; const max = num(d.get("max_sub_quest")); if (max <= 1) return; if (!(await confirmBox("Remove sub quest", `Remove sub ${max} (and all its keys)? Players who are on it lose that progress.`, "Remove", "danger"))) return; for (const v of d.keys(`sub${max}_`)) { delete d.texts[v.key.toLowerCase()]; d.set(v.key, null); } d.set(`sub${max - 1}_next_main_index`, null); d.set(`sub${max - 1}_next_sub_index`, null); d.set("max_sub_quest", String(max - 1)); Q.j = max - 1; renderQuest(); } // ---------- help texts ---------- async function editHelp(section) { const data = await call("q:section", Q.src, Q.text, "help", section); if (!data) return; const d = new Draft("help", section, data); const lines = el("div", { class: "q-help-edit" }); const max = num(d.get("max_line")); for (let l = 1; l <= max; l++) { const count = Math.max(num(d.get(`line${l}_count`)), 1); for (let k = 1; k <= count; k++) { const key = `line${l}_text${k}`; const color = el("input", { type: "text", class: "q-color", value: d.get(`line${l}_color${k}`) || "", title: `line${l}_color${k}` }); color.addEventListener("input", () => d.set(`line${l}_color${k}`, color.value.trim())); const input = el("input", { type: "text", spellcheck: "false", value: d.text(key).trim() }); input.addEventListener("input", () => d.setText(key, input.value || " ")); lines.append(el("div", { class: "q-help-line" }, [el("span", { class: "muted", text: `${l}.${k}` }), color, input])); } } const ok = await modal(`Help [${section}]`, [lines, el("p", { class: "muted", text: "Colors are 0xAARRGGBB. An empty line keeps a space so the layout stays." })], [{ label: "Cancel", value: false }, { label: "Save", kind: "primary", value: true }]); if (!ok || !d.change()) return; if (await save([d.change()], { what: `Saved help [${section}]` })) openQuest(Q.draft.section, { j: Q.j }); } async function newHelp(d, j, helps) { const next = await call("q:next", Q.src, Q.text); const base = next.help; const changes = []; const offsets = [0, 100000, 200000]; const template = helps.find((h) => h.section); for (let i = 0; i < 3; i++) { const srcSec = helps[i]?.section || (template && i === 0 ? template.section : null); const section = `help${base + offsets[i]}`; if (srcSec) { const data = await call("q:section", Q.src, Q.text, "help", srcSec); const c = new Draft("help", section, null, { create: true }); for (const [k, v] of data.entries) c.values.set(k.toLowerCase(), { key: k, raw: v }); for (const [k, t] of Object.entries(data.texts)) c.texts[k] = t; c.set("index", String(base + offsets[i])); changes.push(c.change()); } else if (i < 2) { const c = new Draft("help", section, null, { create: true }); c.set("index", String(base + offsets[i])); c.set("max_line", "1"); c.set("line1_offsetx", "1"); c.set("line1_offsety", "1"); c.set("line1_size", "0.65f"); c.set("line1_count", "1"); c.set("line1_color1", "0xFF3A3A3A"); c.setText("line1_text1", i === 0 ? d.text(`sub${j}_progress`) || "…" : "…"); changes.push(c.change()); } } d.set(`sub${j}_help`, String(base)); const qc = d.change(); if (!(await confirmBox("New help", `Create help ${base} (+100000${changes.length > 2 ? ", +200000" : ""}) and point sub ${j} at it? This also saves the quest's other changes.`, "Create"))) return; if (await save([...changes, qc].filter(Boolean), { what: `Help ${base}` })) { Q.draft = null; await refreshAfterSave(d.section); } } // ---------- reward picker ---------- async function pickReward() { const list = await presentsList(); const search = el("input", { type: "search", placeholder: "RewardIndex, name, type", spellcheck: "false" }); const box = el("div", { class: "q-pick" }); let chosen = null; const draw = () => { const q = search.value.trim().toLowerCase(); const hits = list.filter((p) => !q || String(p.index) === q || p.label.toLowerCase().includes(q) || p.typeName.toLowerCase().includes(q)).slice(0, 300); box.replaceChildren( ...hits.map((p) => el("div", { class: `q-pick-row${chosen === p.index ? " on" : ""}`, onclick: () => ((chosen = p.index), draw()), ondblclick: () => ((chosen = p.index), $("modal-actions").lastChild.click()) }, [iconCanvas(p.icon, 24), el("span", { class: "mono", text: String(p.index) }), el("span", { text: p.label }), el("span", { class: "muted", text: p.typeName })]), ), ); }; search.addEventListener("input", draw); draw(); const ok = await modal("Pick a reward", [search, box, el("p", { class: "muted", text: "New rewards are made in the Rewards tab." })], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]); return ok ? chosen : null; } // ---------- rewards tab ---------- function rTable() { if (Q.rtable) return Q.rtable; Q.rtable = new VTable( $("qr-table"), [ { key: "icon", label: "", width: 36, format: (r) => iconCanvas(r.icon, 26) }, { key: "index", label: "Index", width: 70, cls: "num mono" }, { key: "typeName", label: "Type", width: 110 }, { key: "label", label: "Gives", grow: true, width: 240, title: true }, { key: "v1", label: "Value 1", width: 90, cls: "num mono" }, { key: "v2", label: "Value 2", width: 100, cls: "num mono" }, { key: "used", label: "Used", width: 60, cls: "num", format: (r) => (r.used ? String(r.used) : "") }, ], { multi: false, rowHeight: 32, id: "q-rewards" }, ); Q.rtable.keyOf = (r) => r.index; Q.rtable.onSelect = (_s, r) => r && openReward(r); return Q.rtable; } async function showRewards() { const list = await presentsList(); const types = {}; for (const p of list) types[p.type] = p.typeName; const cur = $("qr-type").value; $("qr-type").replaceChildren(el("option", { value: "", text: "All types" }), ...Object.entries(Q.spec.presentTypes).map(([k, v]) => el("option", { value: k, text: `${k} · ${v.name}` }))); $("qr-type").value = cur; rTable().setRows(list, { keepSelection: true }); filterRewards(); } function filterRewards() { const q = $("qr-search").value.trim().toLowerCase(); const t = $("qr-type").value; const unused = $("qr-unused").checked; rTable().setView((p) => (!t || String(p.type) === t) && (!unused || !p.used) && (!q || String(p.index) === q || p.label.toLowerCase().includes(q) || p.section.toLowerCase().includes(q))); $("qr-count").textContent = `${nf(rTable().view.length)} of ${nf(rTable().rows.length)}`; } const PICK_CAT = { 1: "hero", 3: "etc", 5: "equip", 6: "equipbox", 9: "medal", 12: "costume", 15: "costumebox" }; async function openReward(p, { create = false, copyOf = null, draft = null } = {}) { let d = draft; if (d) { // redraw with the same draft (the type changed) } else if (create) { const next = await call("q:next", Q.src, Q.text); d = new Draft("present", next.presentSection, null, { create: true }); const base = copyOf ? await call("q:section", Q.src, Q.text, "present", copyOf.section) : await call("q:section", Q.src, Q.text, "present", (await presentsList())[0]?.section); for (const [k, v] of base?.entries || []) d.values.set(k.toLowerCase(), { key: k, raw: v }); d.set("RewardIndex", String(next.rewardIndex)); if (!copyOf) { d.set("PresentType", "4"); d.set("PresentValue1", "1000"); d.set("PresentValue2", "0"); } } else d = new Draft("present", p.section, await call("q:section", Q.src, Q.text, "present", p.section)); Q.rDraft = d; const box = $("qr-detail"); const label = el("span", { class: "q-reward-info" }); const preview = async () => { const r = await call("q:presentLabel", Q.src, Q.text, d.get("PresentType"), d.get("PresentValue1"), d.get("PresentValue2")); label.replaceChildren(iconCanvas(r.icon, 32), el("b", { text: r.label })); }; const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveReward() }); d.onChange = () => { saveBtn.disabled = !d.dirty(); clearTimeout(Q.rPrev); Q.rPrev = setTimeout(preview, 250); }; const type = num(d.get("PresentType")); const T = Q.spec.presentTypes[type] || {}; const v1 = fRaw(d, "PresentValue1", T.v1 || "Value 1"); const pickBtn = PICK_CAT[type] ? el("button", { class: "iconbtn", onclick: async () => { const id = await pickItem(PICK_CAT[type], type === 1 ? (x) => x - 700000 : (x) => x); if (id === null) return; v1.querySelector("input").value = String(id); d.set("PresentValue1", String(id)); }, }, [icon("search"), el("span", { text: "Pick item" })]) : null; box.replaceChildren( el("div", { class: "it-head q-head" }, [ el("div", { class: "q-head-text" }, [el("h3", { text: d.create ? "New reward" : `Reward ${d.get("RewardIndex")}` }), el("div", { class: "muted", text: `[${d.section}]${p?.used ? ` · used ${p.used}×` : ""}` })]), el("div", { class: "q-head-actions" }, [ saveBtn, el("button", { class: "iconbtn only", title: "Duplicate as a new reward", disabled: d.create, onclick: () => openReward(null, { create: true, copyOf: p }) }, [icon("copy")]), el("button", { class: "iconbtn only danger", title: "Delete", disabled: d.create, onclick: () => deleteReward(p) }, [icon("trash")]), ]), ]), el("div", { class: "q-reward big" }, [label]), card("Reward", [ fRaw(d, "RewardIndex", "RewardIndex", { hint: "The id quests and missions point to (unique)" }), fEnum(d, "PresentType", "Type", Object.fromEntries(Object.entries(Q.spec.presentTypes).map(([k, v]) => [k, v.name])), 4, { onChange: () => openReward(p, { draft: d }) }), v1, pickBtn ? row("", pickBtn) : null, fRaw(d, "PresentValue2", T.v2 || "Value 2"), el("div", { class: "q-chips" }, [fBool(d, "PresentDirect", "Straight into the inventory (types 1–6, 8–10)")]), fRaw(d, "PresentPeriod", "Days in the gift box", { placeholder: 0 }), fRaw(d, "PresentMent", "Gift message type", { placeholder: 0 }), fRaw(d, "PresentAlarm", "Alarm (0 normal, 1 new)", { placeholder: 0 }), row("Sender", el("span", { text: p?.sender || "(copied from the template)", class: "muted" }), "PresentSendID"), ]), rawCard(d, "", "All keys"), ); preview(); } async function saveReward() { const d = Q.rDraft; const idx = num(d.get("RewardIndex")); const list = await presentsList(); if (list.some((x) => x.index === idx && x.section.toLowerCase() !== d.section.toLowerCase())) return toast(`RewardIndex ${idx} is already used.`, true); const change = d.change(); if (!change) return; if (await save([change], { what: d.create ? `Created reward ${idx}` : `Saved reward ${idx}` })) { Q.presents = null; await showRewards(); const p = (await presentsList()).find((x) => x.index === idx); if (p) openReward(p); } } async function deleteReward(p) { const ok = await confirmBox("Delete reward", `Delete reward ${p.index} [${p.section}]?${p.used ? ` ${p.used} quest(s) / mission(s) still give it: they'd give nothing.` : ""}`, "Delete", "danger"); if (!ok) return; if (await save([{ file: "present", section: p.section, remove: true }], { what: `Deleted reward ${p.index}` })) { Q.presents = null; $("qr-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a reward." })); await showRewards(); } } async function pickItem(cat, toValue) { if (!Q.pick) Q.pick = await call("q:pickLists", Q.src, Q.text); const c = Q.pick.find((x) => x.id === cat); if (!c) return null; const search = el("input", { type: "search", placeholder: "Name or ID", spellcheck: "false" }); const box = el("div", { class: "q-pick" }); let chosen = null; const draw = () => { const q = search.value.trim().toLowerCase(); const hits = c.items.filter((it) => it.name && (!q || String(it.id).includes(q) || it.name.toLowerCase().includes(q))).slice(0, 300); box.replaceChildren( ...hits.map((it) => el("div", { class: `q-pick-row${chosen === it.id ? " on" : ""}`, onclick: () => ((chosen = it.id), draw()), ondblclick: () => ((chosen = it.id), $("modal-actions").lastChild.click()) }, [iconCanvas(it.icon, 24), el("span", { class: "mono", text: String(it.id) }), el("span", { text: it.name })])), ); }; search.addEventListener("input", draw); draw(); const ok = await modal(`Pick: ${c.label}`, [search, box], [{ label: "Cancel", value: false }, { label: "Use", kind: "primary", value: true }]); return ok && chosen !== null ? toValue(chosen) : null; } // ---------- missions tab ---------- function mTable() { if (Q.mtable) return Q.mtable; Q.mtable = new VTable( $("qm-table"), [ { key: "section", label: "Section", width: 90, cls: "mono", sort: (r) => r.n }, { key: "start", label: "Start", width: 80, cls: "mono" }, { key: "end", label: "End", width: 80, cls: "mono" }, { key: "count", label: "Missions", width: 70, cls: "num", format: (r) => String(r.missions.length), sort: (r) => r.missions.length }, { key: "first", label: "Missions", grow: true, width: 240, format: (r) => r.missions.map((m) => m.titles[0].map((x) => x.text).join("")).join(" · "), title: true }, ], { multi: false, rowHeight: 30, id: "q-missions" }, ); Q.mtable.keyOf = (r) => r.section; Q.mtable.onSelect = (_s, r) => r && openMission(r.section); return Q.mtable; } async function showMissions() { const kind = Q.mKind; if (!Q.missions[kind]) Q.missions[kind] = await call("q:missions", Q.src, Q.text, kind); mTable().setRows(Q.missions[kind], { keepSelection: true }); filterMissions(); } function filterMissions() { const q = $("qm-search").value.trim().toLowerCase(); mTable().setView((r) => !q || r.section.toLowerCase().includes(q) || r.start.includes(q) || r.missions.some((m) => String(m.code) === q || m.titles.some((t) => t.some((x) => x.text.toLowerCase().includes(q))))); $("qm-count").textContent = `${nf(mTable().view.length)} of ${nf(mTable().rows.length)}`; } async function openMission(section, { draft = null } = {}) { if (Q.mDraft?.dirty() && Q.mDraft.section !== section && !draft && !(await confirmBox("Unsaved changes", `[${Q.mDraft.section}] has unsaved changes. Drop them?`, "Drop changes", "danger"))) return; const kind = Q.mKind; const d = draft || new Draft(kind, section, await call("q:section", Q.src, Q.text, kind, section)); Q.mDraft = d; const info = Q.missions[kind]?.find((x) => x.section === section); const saveBtn = el("button", { class: "btn primary", text: d.create ? "Create" : "Save", disabled: !d.dirty(), onclick: () => saveMission() }); d.onChange = () => (saveBtn.disabled = !d.dirty()); let count = 0; while (num(d.get(`mission${count + 1}_code`))) count++; const cards = []; for (let k = 1; k <= count; k++) { const M = (x) => `mission${k}_${x}`; const type = num(d.get(M("type"))); const mt = Q.spec.missionTypes[type]; const rewardInfo = el("span", { class: "q-reward-info" }); fillReward(rewardInfo, num(d.get(M("present")))); const presentInput = fRaw(d, M("present"), "Reward (RewardIndex)", { onInput: (v) => fillReward(rewardInfo, num(v)) }); const texts = d.keys(`mission${k}_`).filter((v) => /_(title\d_text\d|present_text\d)$/i.test(v.key)); cards.push( card(`Mission ${k} · ${info?.missions[k - 1]?.titles[0].map((x) => x.text).join("") || d.text(M("title1_text1")) || ""}`, [ fRaw(d, M("code"), "Code", { hint: "Unique per file: progress is stored by code" }), fEnum(d, M("type"), "Type", Object.fromEntries(Object.entries(Q.spec.missionTypes).map(([k2, v]) => [k2, v.name])), type, { onChange: () => openMission(section, { draft: d }) }), fRaw(d, M("values"), "Values", { hint: mt ? `v1.v2.v3.v4: ${mt.values.join(" · ")}` : "v1.v2.v3.v4" }), mt ? el("small", { class: "muted q-note", text: mt.values.map((v, i) => `v${i + 1} = ${v}`).join(" · ") }) : null, presentInput, row("", el("div", { class: "q-inline" }, [rewardInfo, el("button", { class: "iconbtn", onclick: async () => { const r = await pickReward(); if (r) { presentInput.querySelector("input").value = String(r); d.set(M("present"), String(r)); fillReward(rewardInfo, r); } } }, [icon("search"), el("span", { text: "Pick" })])])), iconField(d, M("icon"), "Icon"), ...texts.map((v) => fText(d, v.key, v.key.replace(/^mission\d+_/i, "").replace(/_/g, " "))), ]), ); } $("qm-detail").replaceChildren( el("div", { class: "it-head q-head" }, [ el("div", { class: "q-head-text" }, [el("h3", { text: `${Q.mKind[0].toUpperCase()}${Q.mKind.slice(1)} [${d.section}]` }), el("div", { class: "muted", text: `${count} mission(s)${d.create ? " · new, not saved yet" : ""}` })]), el("div", { class: "q-head-actions" }, [saveBtn, el("button", { class: "iconbtn only danger", title: "Delete this date", disabled: d.create, onclick: () => deleteMission() }, [icon("trash")])]), ]), card("Date", [ fRaw(d, "start_date", "Starts (YYMMDD)", { hint: "start_date, e.g. 251001; the server turns this date on at reset_hour" }), Q.mKind === "weekly" ? fRaw(d, "end_date", "Ends (YYMMDD)") : null, el("div", { class: "q-inline" }, [ el("button", { class: "iconbtn", onclick: () => addMission(d, count) }, [icon("plus"), el("span", { text: "Add mission (copy of the last)" })]), el("button", { class: "iconbtn danger", disabled: !count, onclick: () => removeMission(d, count) }, [icon("trash"), el("span", { text: "Remove last" })]), ]), ]), ...cards, rawCard(d, "", "All keys"), ); } async function addMission(d, count) { const next = await call("q:next", Q.src, Q.text); const from = `mission${count}_`; const to = `mission${count + 1}_`; const used = new Set([...d.values.values()].filter((v) => /_code$/i.test(v.key)).map((v) => num(v.raw))); let code = next.missionCode[Q.mKind]; while (used.has(code)) code++; for (const v of d.keys(from)) { const k = to + v.key.slice(from.length); if (v.key.toLowerCase() in d.texts) d.texts[k.toLowerCase()] = d.texts[v.key.toLowerCase()]; d.values.set(k.toLowerCase(), { key: k, raw: /STR\(/i.test(v.raw) ? "STR(0)" : v.raw }); } d.set(`${to}code`, String(code)); openMission(d.section, { draft: d }); } function removeMission(d, count) { for (const v of d.keys(`mission${count}_`)) { delete d.texts[v.key.toLowerCase()]; d.set(v.key, null); } openMission(d.section, { draft: d }); } async function newMissionDate() { const sel = mTable().selected()[0] || Q.missions[Q.mKind]?.at(-1); if (!sel) return; const next = await call("q:next", Q.src, Q.text); const data = await call("q:section", Q.src, Q.text, Q.mKind, sel.section); const d = new Draft(Q.mKind, next.missionSection[Q.mKind], null, { create: true }); for (const [k, v] of data.entries) d.values.set(k.toLowerCase(), { key: k, raw: v }); for (const [k, t] of Object.entries(data.texts)) d.texts[k] = t; // new codes for every mission let code = next.missionCode[Q.mKind]; for (const v of d.values.values()) if (/^mission\d+_code$/i.test(v.key)) v.raw = String(code++); mTable().clearSelection(); openMission(d.section, { draft: d }); toast(`Copy of [${sel.section}] as [${d.section}] with new mission codes. Set its dates, then Create.`); } async function saveMission() { const d = Q.mDraft; const change = d.change(); if (!change) return; if (await save([change], { what: `Saved ${Q.mKind} [${d.section}]` })) { Q.mDraft = null; delete Q.missions[Q.mKind]; await showMissions(); openMission(d.section); } } async function deleteMission() { const d = Q.mDraft; if (!(await confirmBox("Delete date", `Delete ${Q.mKind} [${d.section}]?`, "Delete", "danger"))) return; if (await save([{ file: Q.mKind, section: d.section, remove: true }], { what: `Deleted [${d.section}]` })) { Q.mDraft = null; delete Q.missions[Q.mKind]; $("qm-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a date." })); await showMissions(); } } // ---------- checks tab ---------- function cTable() { if (Q.ctable) return Q.ctable; Q.ctable = new VTable( $("qc-table"), [ { key: "sev", label: "", width: 70, format: (r) => el("b", { class: `q-sev ${r.sev}`, text: r.sev }) }, { key: "file", label: "File", width: 90 }, { key: "section", label: "Section", width: 120, cls: "mono" }, { key: "sub", label: "Sub", width: 50, cls: "num", format: (r) => (r.sub ? String(r.sub) : "") }, { key: "msg", label: "Problem", grow: true, width: 400, title: true }, ], { multi: false, rowHeight: 28, id: "q-checks" }, ); Q.ctable.keyOf = (r) => `${r.file}|${r.section}|${r.sub}|${r.msg}`; Q.ctable.onOpen = (r) => r && jump(r); return Q.ctable; } async function runChecks() { $("qc-count").textContent = "Checking…"; const list = await call("q:check", Q.src, Q.text); cTable().setRows(list); filterChecks(); } function filterChecks() { const sev = $("qc-sev").value; cTable().setView((r) => !sev || r.sev === sev); const all = cTable().rows; $("qc-count").textContent = `${nf(all.filter((r) => r.sev === "error").length)} errors · ${nf(all.filter((r) => r.sev === "warn").length)} warnings · double-click to open`; } function jump(r) { if (r.file === "quest") { subtab("quests"); openQuest(r.section, { j: r.sub || 1 }); } else if (r.file === "present") { subtab("rewards"); presentsList().then((l) => { const p = l.find((x) => x.section.toLowerCase() === r.section.toLowerCase()); if (p) openReward(p); }); } else { Q.mKind = r.file; for (const b of $("qm-kind").children) b.classList.toggle("on", b.dataset.kind === r.file); subtab("missions"); openMission(r.section); } } // ---------- wiring ---------- function subtab(name) { Q.sub = name; for (const b of $("q-subtabs").children) b.classList.toggle("on", b.dataset.sub === name); for (const s of document.querySelectorAll("#view-quests .q-sub")) s.classList.toggle("on", s.dataset.sub === name); if (!Q.data) return; if (name === "rewards") showRewards(); if (name === "missions") showMissions(); if (name === "checks" && !Q.ctable?.rows.length) runChecks(); } for (const b of $("q-subtabs").children) b.addEventListener("click", () => subtab(b.dataset.sub)); for (const b of $("qm-kind").children) b.addEventListener("click", () => { Q.mKind = b.dataset.kind; for (const x of $("qm-kind").children) x.classList.toggle("on", x === b); Q.mDraft = null; $("qm-detail").replaceChildren(el("div", { class: "it-empty muted", text: "Pick a date." })); showMissions(); }); $("q-search").addEventListener("input", filterQuests); $("q-class").addEventListener("change", filterQuests); $("q-filter").addEventListener("change", filterQuests); $("q-new").addEventListener("click", newQuest); $("qr-search").addEventListener("input", filterRewards); $("qr-type").addEventListener("change", filterRewards); $("qr-unused").addEventListener("change", filterRewards); $("qr-new").addEventListener("click", () => openReward(null, { create: true, copyOf: Q.rtable?.selected()[0] || null })); $("qm-search").addEventListener("input", filterMissions); $("qm-new").addEventListener("click", newMissionDate); $("qc-run").addEventListener("click", runChecks); $("qc-sev").addEventListener("change", filterChecks); $("q-text").addEventListener("change", () => { Q.text = $("q-text").value; load(); }); $("q-reload").addEventListener("click", async () => { if (!(await leaveDraft())) return; Q.draft = null; UIRes.reset(); load({ reload: true }); }); $("q-folder").addEventListener("click", async () => { const dir = await call("dialog:folder", "Game folder (with rs.iop / map.iop)"); if (!dir) return; Q.src = { kind: "packs", folder: dir, name: dir }; Q.data = null; Q.draft = null; UIRes.reset(); load(); }); $("q-export").addEventListener("click", async () => { if (!Q.data) return; const dir = await call("dialog:folder", "Folder to write the server's quest / reward / mission INIs into"); if (!dir) return; try { const files = await call("q:export", Q.src, Q.text, dir); toast(`Wrote ${files.join(", ")} — copy them into the game server's config folder, then reload quests on the server.`); } catch (err) { toast(err.message, true); } }); document.querySelector('#tabs [data-view="quests"]').addEventListener("click", () => { if (!Q.data) load(); }); window.addEventListener("profile-changed", () => { Q.src = null; Q.data = null; Q.draft = null; if ($("view-quests").classList.contains("on")) load(); }); })();