1254 lines
60 KiB
JavaScript
1254 lines
60 KiB
JavaScript
"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();
|
||
});
|
||
})();
|