Add web-based save editor for "Reksio i Czarodzieje"

- 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>
This commit is contained in:
Patryk Gensch
2026-08-03 01:18:08 +02:00
co-authored by Codex
commit edd8ceda9e
17 changed files with 2572 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
"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));
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reksio i Czarodzieje — edytor zapisów</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Reksio i&nbsp;Czarodzieje <span class="muted">— edytor zapisów</span></h1>
<div id="root-path" class="muted mono"></div>
</header>
<nav id="slots" aria-label="Sloty"></nav>
<main>
<aside>
<input id="filter" type="search" placeholder="filtruj pliki slotu…" autocomplete="off">
<ul id="manifest"></ul>
</aside>
<section id="panel">
<p class="muted">Wybierz slot.</p>
</section>
</main>
<div id="toast" hidden></div>
<script src="app.js"></script>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
:root {
--bg: #16130f;
--panel: #201b16;
--line: #3a3129;
--ink: #ece4d8;
--muted: #a2957f;
--accent: #d8a13a;
--warn: #d4674a;
--ok: #7fa650;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.muted { color: var(--muted); font-weight: 400; }
header {
padding: 14px 20px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 17px; font-weight: 600; }
nav {
display: flex;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
nav button {
background: var(--panel);
border: 1px solid var(--line);
color: var(--ink);
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font: inherit;
text-align: left;
}
nav button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
nav button .sub { display: block; font-size: 12px; color: var(--muted); }
nav button[disabled] { opacity: .45; cursor: default; }
main {
display: grid;
grid-template-columns: minmax(200px, 280px) 1fr;
gap: 0;
align-items: start;
}
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
aside {
border-right: 1px solid var(--line);
padding: 12px;
position: sticky;
top: 0;
max-height: 100vh;
overflow-y: auto;
}
aside input {
width: 100%;
padding: 7px 9px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
font: inherit;
margin-bottom: 10px;
}
aside ul { list-style: none; margin: 0; padding: 0; }
aside li button {
width: 100%;
text-align: left;
background: none;
border: 0;
color: var(--ink);
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
font: inherit;
}
aside li button:hover { background: var(--panel); }
aside li button[aria-current="true"] { background: var(--panel); color: var(--accent); }
aside li .file { display: block; font-size: 11px; color: var(--muted); }
section#panel { padding: 18px 20px 60px; min-width: 0; }
h2 { font-size: 15px; margin: 22px 0 10px; }
h2:first-child { margin-top: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
}
.card .k { font-size: 12px; color: var(--muted); }
.card .v { font-size: 15px; margin-top: 2px; word-break: break-word; }
.scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { padding: 5px 9px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { background: var(--panel); position: sticky; top: 0; font-weight: 600; }
tbody tr:hover { background: #1c1814; }
td input {
background: transparent;
border: 1px solid transparent;
color: inherit;
font: inherit;
padding: 2px 4px;
border-radius: 4px;
/* szerokość sterowana atrybutem size — inaczej długie ścieżki w NAME są ucinane */
min-width: 4ch;
}
td input:hover { border-color: var(--line); }
td input:focus { border-color: var(--accent); outline: none; background: var(--bg); }
td input.changed { color: var(--accent); border-color: var(--accent); }
td.idx { color: var(--muted); }
.actions { display: flex; gap: 8px; margin: 14px 0; flex-wrap: wrap; align-items: center; }
.actions button {
background: var(--accent);
border: 0;
color: #241c0c;
border-radius: 6px;
padding: 8px 16px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.actions button.ghost { background: none; border: 1px solid var(--line); color: var(--ink); font-weight: 400; }
.actions button[disabled] { opacity: .4; cursor: default; }
.problems { border-left: 3px solid var(--warn); padding: 8px 12px; background: var(--panel); border-radius: 0 6px 6px 0; }
.problems li { margin: 2px 0; }
.ok { color: var(--ok); }
#toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 10px 18px;
box-shadow: 0 6px 24px #0008;
}
#toast.err { border-color: var(--warn); }