"use strict"; const state = { slot: null, key: null, // aktualnie otwarty plik z manifestu, null = przegląd manifest: [], edits: new Map(), // "wiersz:kolumna" -> nowa wartość }; const $ = (sel) => document.querySelector(sel); const el = (tag, props = {}, kids = []) => { const node = Object.assign(document.createElement(tag), props); for (const kid of [].concat(kids)) { node.append(kid instanceof Node ? kid : document.createTextNode(kid)); } return node; }; async function api(path, body) { const res = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), } : undefined); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); return data; } let toastTimer; function toast(message, isError = false) { const node = $("#toast"); node.textContent = message; node.classList.toggle("err", isError); node.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { node.hidden = true; }, 3500); } // ---- sloty ---------------------------------------------------------------- async function boot() { const data = await api("/api/slots"); $("#root-path").textContent = data.root; const nav = $("#slots"); nav.replaceChildren(); for (const slot of data.slots) { const label = slot.live ? "slot 0 · żywy stan" : `slot ${slot.number}`; const sub = slot.exists ? (slot.location || "?") : "pusty"; const button = el("button", { type: "button", disabled: !slot.exists }, [ label, el("span", { className: "sub" }, sub), ]); button.dataset.slot = String(slot.number); button.setAttribute("aria-pressed", "false"); button.onclick = () => selectSlot(slot.number); nav.append(button); } const first = data.slots.find((s) => s.exists); if (first) selectSlot(first.number); } async function selectSlot(number) { state.slot = number; state.key = null; state.edits.clear(); document.querySelectorAll("#slots button").forEach((b) => { b.setAttribute("aria-pressed", String(Number(b.dataset.slot) === number)); }); const { entries } = await api(`/api/manifest?slot=${number}`); state.manifest = entries; renderManifest(); renderOverview(); } function renderManifest() { const needle = $("#filter").value.trim().toLowerCase(); const list = $("#manifest"); list.replaceChildren(); const overview = el("li", {}, [el("button", { type: "button" }, "Przegląd slotu")]); overview.firstChild.setAttribute("aria-current", String(state.key === null)); overview.firstChild.onclick = () => { state.key = null; renderManifest(); renderOverview(); }; list.append(overview); for (const entry of state.manifest) { const haystack = `${entry.key} ${entry.label} ${entry.file}`.toLowerCase(); if (needle && !haystack.includes(needle)) continue; const button = el("button", { type: "button" }, [ entry.label, el("span", { className: "file mono" }, entry.file), ]); button.setAttribute("aria-current", String(state.key === entry.key)); button.onclick = () => openEntry(entry.key); list.append(el("li", {}, button)); } } // ---- przegląd ------------------------------------------------------------- async function renderOverview() { const panel = $("#panel"); panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…")); const data = await api(`/api/overview?slot=${state.slot}`); panel.replaceChildren(); panel.append(el("h2", {}, "Stan")); if (data.problems.length) { panel.append(el("ul", { className: "problems" }, data.problems.map((p) => el("li", {}, p)))); } else { panel.append(el("p", { className: "ok" }, "Niezmienniki OK.")); } panel.append(el("h2", {}, "Zmienne globalne — GAME.ARR")); const grid = el("div", { className: "grid" }); for (const field of data.gamesets) { grid.append(el("div", { className: "card" }, [ el("div", { className: "k mono" }, field.name), el("div", { className: "v" }, String(field.value ?? "(brak)")), el("div", { className: "k" }, field.note || ""), ])); } panel.append(grid); panel.append(el("h2", {}, "Ekwipunek")); panel.append(renderReadonlyTable( ["GUID", "NAME", "PARENT", "BASE", "EMPTY"], data.items.map((r) => [r.GUID, r.NAME, r.PARENT, r.BASE, r.EMPTY]), )); panel.append(el("p", { className: "muted" }, "EMPTY = 1 oznacza slot zajęty (tak, odwrotnie niż sugeruje nazwa).")); panel.append(el("h2", {}, "Czary i doświadczenie — SPELLS.ARR")); const exp = data.experience; if (exp) { panel.append(el("p", { className: "muted" }, `Klepsydra: ${exp.value}/${exp.threshold} doświadczenia ` + `(klatka ${exp.frame}/${exp.frames}), awansów: ${exp.level}` + (exp.blinking ? " — miga, można nauczyć się czaru." : "."))); panel.append(el("p", { className: "muted" }, "Znane czary: " + (exp.known.length ? exp.known.join(", ") : "brak"))); } else { panel.append(el("p", { className: "problems" }, staleServerNote())); } panel.append(renderReadonlyTable( ["#", "wartość", "znaczenie"], data.spells.map((s) => [String(s.index), String(s.value), s.note || "—"]), )); } function staleServerNote() { // Strona statyczna odświeża się sama, ale server.py ładuje się przy starcie — // rozjazd wersji objawia się brakiem pól w odpowiedzi. Powiedz to wprost, // zamiast renderować undefined/NaN. return "Serwer odpowiada bez bloku doświadczenia — najpewniej działa na starszym " + "kodzie niż ta strona. Zrestartuj `ricsave serve`."; } function renderReadonlyTable(headers, rows) { const table = el("table", {}, [ el("thead", {}, el("tr", {}, headers.map((h) => el("th", {}, h)))), el("tbody", {}, rows.map((row) => el("tr", {}, row.map((c) => el("td", {}, String(c)))))), ]); return el("div", { className: "scroll" }, table); } // ---- edycja pliku --------------------------------------------------------- async function openEntry(key) { state.key = key; state.edits.clear(); renderManifest(); const panel = $("#panel"); panel.replaceChildren(el("p", { className: "muted" }, "Wczytuję…")); const data = await api(`/api/raw?slot=${state.slot}&key=${encodeURIComponent(key)}`); panel.replaceChildren(); panel.append(el("h2", {}, `${data.label} — ${key}`)); const isDta = data.kind === "dta"; const columns = isDta ? (key === "items" ? ["GUID", "NAME", "PARENT", "BASE", "EMPTY"] : ["NAME", "IDNAME", "TYPE", "SPARAM0", "SPARAM1", "SPARAM2", "IPARAM0", "IPARAM1", "IPARAM2"]) : ["wartość"]; const rows = isDta ? data.value : data.value.map((v) => [v]); const save = el("button", { type: "button", disabled: true }, "Zapisz na dysk"); const revert = el("button", { type: "button", className: "ghost", disabled: true }, "Cofnij zmiany"); const counter = el("span", { className: "muted" }, `${rows.length} wierszy`); panel.append(el("div", { className: "actions" }, [save, revert, counter])); const body = el("tbody", {}); rows.forEach((row, r) => { const cells = [el("td", { className: "idx mono" }, String(r))]; row.forEach((value, c) => { const input = el("input", { value: String(value), className: "mono" }); input.size = Math.max(4, Math.min(40, String(value).length + 2)); input.oninput = () => { const id = `${r}:${c}`; if (input.value === String(value)) state.edits.delete(id); else state.edits.set(id, input.value); input.classList.toggle("changed", state.edits.has(id)); save.disabled = revert.disabled = state.edits.size === 0; counter.textContent = state.edits.size ? `${rows.length} wierszy · ${state.edits.size} zmian` : `${rows.length} wierszy`; }; cells.push(el("td", {}, input)); }); body.append(el("tr", {}, cells)); }); panel.append(el("div", { className: "scroll" }, el("table", {}, [ el("thead", {}, el("tr", {}, [el("th", {}, "#"), ...columns.map((h) => el("th", {}, h))])), body, ]))); if (isDta && key.startsWith("scene:")) { panel.append(el("p", { className: "muted" }, "Nie dodawaj ani nie usuwaj wierszy — skrypty scen adresują je po indeksie. " + "Dla obiektów z TYPE = 2 kolumna IPARAM0 to flaga widoczności (1 = jest, 0 = zabrany).")); } revert.onclick = () => openEntry(key); save.onclick = async () => { save.disabled = true; const next = rows.map((row, r) => row.map((value, c) => { const edited = state.edits.get(`${r}:${c}`); if (edited === undefined) return value; return (!isDta && typeof value === "number") ? Number(edited) : edited; })); try { const res = await api("/api/write", { slot: state.slot, key, value: isDta ? next : next.map((row) => row[0]), }); toast(`zapisano: ${res.written.join(", ")}`); openEntry(key); } catch (err) { toast(err.message, true); save.disabled = false; } }; } $("#filter").addEventListener("input", renderManifest); boot().catch((err) => toast(err.message, true));