Files

1254 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();
});
})();