- Implemented a local web server using http.server to serve the save editor. - Created a user interface with HTML, CSS, and JavaScript for managing game slots and editing save files. - Added API endpoints for retrieving slot information, overview, and raw data for editing. - Implemented functionality for saving changes back to the game files. - Added tests for ARR and DTA formats to ensure data integrity during round-trip serialization. Co-authored-by: Codex <noreply@openai.com>
249 lines
9.0 KiB
JavaScript
249 lines
9.0 KiB
JavaScript
"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));
|