Zum Inhalt
wannacode

Speichern und Laden

Kurz gesagt: Ein Spielstand ist die Welt als Text „Schlüssel=Wert“ mit einer Version oben und einer Prüfsumme unten; das Laden liest und prüft alles in lokalen Variablen und fasst das Spiel erst am Ende an, darum wird ein kaputter Spielstand abgelehnt, ohne es zu beschädigen.

Problem

Das Spiel, das alles vergisst

Ein Spieler hat zwei Stunden in Graufurt verbracht: Rüstung genommen, den Wächter der Mine besiegt, Gold gesammelt. Er schließt das Terminal. Am nächsten Morgen beginnt das Spiel von vorn, und er kommt nicht wieder. Speichern und Laden machen aus einem Spiel statt einer Sitzung eine Reise.

Die Aufgabe sieht einfach aus („schreib die Variablen in eine Datei“), aber dahinter stehen drei Fragen:

  1. Was wird gespeichert? Nicht der Code und nicht die Tabellen (Gegenstände, Gegner, Fertigkeiten: Sie sind im Spiel). Gespeichert wird nur, was das Spiel verändert hat: der Held, der Zug, die Karte, die Gegner, die Quests. Der Rest wird neu aufgebaut.
  2. Woher weiß das Laden, dass die Datei gut ist? Die Datei kann zu einem anderen Spiel gehören, aus einer älteren oder neueren Version stammen, auf halbem Weg abgeschnitten oder von Hand verändert sein.
  3. Was tut es, wenn sie nicht gut ist? Es stürzt nicht ab und bleibt nicht halb geladen.

Die Lektion baut eine kleine Welt (ein Held, ein Beutel, ein Zug) und führt alle drei Fragen durch sie: der Spielstand als Text, eine Version und eine Prüfsumme, ein Laden, das zuerst liest und prüft, und sechs kaputte Spielstände. Danach zeigt sie dasselbe im echten Spiel.

Die Idee

Die Welt als Text: Schlüssel=Wert

Ein Spielstand ist einfacher Text, Zeile für Zeile: ein Schlüssel, ein Gleichheitszeichen, ein Wert. Eine Zeile ist eine Tatsache. So lässt sich die Datei mit den Augen lesen, in einem Editor mit einem anderen Spielstand vergleichen und braucht keine Bibliothek. Hier die Typen der kleinen Welt: ein Held mit Beutel, ein Spiel und ein Fehler für Spielstände.

class Slot {
constructor(id, count) {
this.id = id;
this.count = count;
}
}
class Hero {
constructor(name, level, hp, gold, x, y) {
this.name = name;
this.level = level;
this.hp = hp;
this.gold = gold;
this.x = x;
this.y = y;
this.inventory = [];
}
}
class Game {
constructor(hero, turn) {
this.hero = hero;
this.turn = turn;
}
}
class SaveError extends Error {}

Und hier der Spielstand. Ganz oben stehen Format und Version, dann die Welt, und ganz unten die Prüfsumme (dazu gleich mehr):

const SAVE_FORMAT = "ARCANUM";
const SAVE_VERSION = 1;
// Die Summe entsteht aus den Zeichen von allem darüber; ändert jemand nur einen Buchstaben, stimmt sie nicht mehr.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
// Eine leere Liste wird als Strich geschrieben, damit die Zeile nie einen leeren Wert hat.
function joinOrDash(parts) {
return parts.length === 0 ? "-" : parts.join(",");
}
function sign(body) {
return body + "checksum=" + checksum(body) + "\n";
}
function saveGame(game) {
const hero = game.hero;
const lines = [];
lines.push("format=" + SAVE_FORMAT);
lines.push("version=" + SAVE_VERSION);
lines.push("turn=" + game.turn);
lines.push("hero.name=" + hero.name);
lines.push("hero.level=" + hero.level);
lines.push("hero.hp=" + hero.hp);
lines.push("hero.gold=" + hero.gold);
lines.push("hero.x=" + hero.x);
lines.push("hero.y=" + hero.y);
const bag = [];
for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count);
lines.push("hero.inventory=" + joinOrDash(bag));
return sign(lines.join("\n") + "\n");
}

Der Beutel ist eine Zeile: Gegenstand:Anzahl, durch Kommas getrennt. Ein leerer Beutel wird als Strich geschrieben: Eine Zeile hero.inventory= mit leerem Wert sähe aus wie eine fehlende. Jede Zeile entsteht mit Schlüssel=Wert, alle werden mit Zeilenumbrüchen verbunden, und am Ende setzt sign die Prüfsumme. Das Programm speichert Iva und danach dasselbe Spiel mit leerem Beutel (nur die zwei Zeilen, die sich ändern):

const game = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
game.hero.inventory.push(new Slot("potion_heal", 2));
game.hero.inventory.push(new Slot("leather", 1));
process.stdout.write(saveGame(game));
game.hero.inventory.length = 0;
const again = saveGame(game).split("\n");
console.log(again[9]);
console.log(again[10]);
Ausgabe
format=ARCANUM
version=1
turn=212
hero.name=Iva
hero.level=3
hero.hp=27
hero.gold=42
hero.x=5
hero.y=2
hero.inventory=potion_heal:2,leather:1
checksum=624337035
hero.inventory=-
checksum=126903799

Die zweite Prüfsumme ist ganz anders, obwohl sich eine Zeile geändert hat: Das ist ihr Zweck.

Genauer, für Neugierige

Was der Kanon über unsere Welt hinaus speichert: den Seed und den Zustand des Generators (seed, rng, ids), damit nach dem Laden die Würfel derselben Folge folgen; die Ebenen (drei Karten, Zeile für Zeile, mit dem Gesehenen und der Dekoration); die Gegner mit Leben, Zustand und Effekten; die Quests und die Flags. Das sind nicht die Tabellen der Gegenstände und Gegner: Sie sind im Spiel, und ein Spielstand behält nur die id (potion_heal:2), nicht den ganzen Gegenstand.

Was nicht gespeichert wird, weil es neu berechnet wird: die Werte mit ihren Schichten (Basis, Ausrüstung, Effekte), das Sichtfeld (nach dem Laden ruft das Spiel computeFov auf) und alles, was aus den Tabellen gebaut wird. Weniger im Spielstand heißt weniger Stellen für Fehler.

Die Idee

Version und Prüfsumme

Zwei Zeilen schützen den Spielstand vor unangenehmen Überraschungen.

Die Version (version=1) sagt, welche Variante des Formats die Datei geschrieben hat. Das Spiel wird sich ändern: Es wird Mana geben, es wird neue Zeilen geben. Ein altes Spiel, das einen neuen Spielstand liest, darf nicht raten; und das neue Spiel, das einen alten liest, muss entweder wissen, wie, oder klar ablehnen. Darum ist die Version die zweite Zeile und wird vor allem anderen geprüft, außer dem Kopf.

Die Prüfsumme (checksum=...) ist eine Zahl aus allen Zeilen über ihr. Hier ist sie ganz:

// Die Summe entsteht aus den Zeichen von allem darüber; ändert jemand nur einen Buchstaben, stimmt sie nicht mehr.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}

Sie läuft über die Zeichen des Textes: Die Summe wird mit 31 multipliziert und der Code des Zeichens addiert, und der Rest von 1000000007 hält sie klein. Die Multiplikation ist wichtig. Würde sie nur die Codes addieren, wären „ab“ und „ba“ dieselbe Zahl. Das ergibt sich daraus:

// Die Summe entsteht aus den Zeichen von allem darüber; ändert jemand nur einen Buchstaben, stimmt sie nicht mehr.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
console.log(checksum("ab"));
console.log(checksum("ba"));
Antwort

Der Spielstand ist fertig, wenn die Summe von allem über der letzten Zeile ganz unten steht. Wer ein einziges Zeichen ändert, sieht die Summe nicht mehr stimmen.

Zahlen in JavaScript sind double, aber sum * 31 bleibt unter 2^53 (etwa 31 · 10^9), darum ist die Rechnung exakt. charCodeAt(i) ist der Code des Zeichens.
// Eine leere Liste wird als Strich geschrieben, damit die Zeile nie einen leeren Wert hat.
function joinOrDash(parts) {
  return parts.length === 0 ? "" : parts.join(",");
}

Die Idee

Laden: erst lesen, dann anfassen

Laden ist das Spiegelbild des Speicherns und hat eine Regel: Solange nicht alles gelesen und geprüft ist, wird das Spiel nicht angefasst. Alles wird in lokale Variablen gelesen, und die Zuweisung an das Spiel ist das Letzte in der Methode.

Zuerst die Prüfungen. readValues nimmt den Text und gibt eine Tabelle Schlüssel → Wert zurück, und bei allem Falschen wirft es einen SaveError mit der Meldung für den Spieler:

// Nur Ziffern, höchstens neun.
function isNumber(text) {
if (text.length === 0 || text.length > 9) return false;
for (const c of text) {
if (c < "0" || c > "9") return false;
}
return true;
}
function need(values, key) {
if (!values.has(key)) throw new SaveError(`${key} fehlt`);
return values.get(key);
}
function needNumber(values, key) {
const value = need(values, key);
if (!isNumber(value)) throw new SaveError(`falscher Wert bei ${key}`);
return Number(value);
}
function readValues(text) {
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("das ist kein Arkanum-Spielstand");
const values = new Map();
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf("=");
if (eq <= 0) throw new SaveError(`Zeile ${i + 1} hat nicht die Form Schlüssel=Wert`);
values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = need(values, "version");
if (version !== String(SAVE_VERSION)) throw new SaveError(`Version ${version}, aber das Spiel liest Version ${SAVE_VERSION}`);
const last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError(`checksum fehlt`);
const body = lines.slice(0, lines.length - 1).join("\n") + "\n";
const actual = checksum(body);
const stored = last.substring(9);
if (stored !== String(actual)) throw new SaveError(`die Prüfsumme stimmt nicht (gespeichert ${stored}, berechnet ${actual}), jemand hat die Datei verändert`);
return values;
}

Die Reihenfolge ist wichtig: der Kopf (format=ARCANUM), jede Zeile als Schlüssel=Wert, die Version, die Prüfsumme. Die Prüfsumme wird aus allem über der letzten Zeile berechnet und mit der dort gespeicherten verglichen; need und needNumber sind die Wächter der Schlüssel (fehlt er, und ist die Zahl eine Zahl).

Danach der Held und das Laden selbst:

function readHero(values) {
const name = need(values, "hero.name");
const level = needNumber(values, "hero.level");
const hp = needNumber(values, "hero.hp");
const gold = needNumber(values, "hero.gold");
const x = needNumber(values, "hero.x");
const y = needNumber(values, "hero.y");
const hero = new Hero(name, level, hp, gold, x, y);
const bag = need(values, "hero.inventory");
if (bag !== "-") {
for (const part of bag.split(",")) {
const pieces = part.split(":");
if (pieces.length !== 2 || !isNumber(pieces[1])) throw new SaveError(`falscher Wert bei hero.inventory`);
hero.inventory.push(new Slot(pieces[0], Number(pieces[1])));
}
}
return hero;
}
// Gibt null zurück, wenn alles gut ging, sonst den Text des Problems.
function loadGame(game, text) {
try {
const values = readValues(text);
const hero = readHero(values);
const turn = needNumber(values, "turn");
// Alles ist gelesen und geprüft: erst jetzt wird das Spiel angefasst.
game.hero = hero;
game.turn = turn;
return null;
} catch (error) {
if (!(error instanceof SaveError)) throw error;
return `Der Spielstand ist kaputt: ${error.message}. Das Spiel geht ohne ihn weiter.`;
}
}

readHero baut den Helden aus der Tabelle; loadGame ist eine kurze Hülle: Sie liest den Helden und den Zug und weist sie erst dann zu. SaveError wird gefangen und wird zu Text; was herauskommt, ist das Spiel, unverändert. Das Ergebnis ist null bei Erfolg oder eine Meldung für den Spieler. Hier ein Spielstand und ein Laden in ein anderes Spiel:

const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const text = saveGame(iva);
const copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
const error = loadGame(copy, text);
console.log(error === null ? "ok" : error);
console.log(heroLine(copy.hero));
console.log(saveGame(copy) === text ? "Den Spielstand erneut zu schreiben ergibt denselben Text." : "Den Spielstand erneut zu schreiben ergibt einen anderen Text.");
Ausgabe
ok
Iva: Stufe 3, Leben 27, Gold 42
Den Spielstand erneut zu schreiben ergibt denselben Text.

Der Spielstand des geladenen Spiels ist derselbe Text wie der erste: Darum sind Speichern und Laden Spiegelbilder. Boris ist weg, Iva ist im Spiel.

Number("") ist 0 und Number(" 7") ist 7, darum prüft isNumber selbst, dass nur Ziffern da sind (höchstens neun). catch (error) fängt in JavaScript alles, darum gibt loadGame nur unsere eigenen SaveError zurück und wirft fremde Fehler erneut: Ein Fehler im Code darf nicht als „kaputter Spielstand“ getarnt werden.
Genauer, für Neugierige

Das kanonische loadGame tut noch etwas: Fällt beim Lesen ein Fehler, der kein SaveError ist (zum Beispiel ein von Hand zerstörter Spielstand mit richtiger Prüfsumme und einer Zahl unerwarteter Form in einer Zeile, die mit toInt gelesen wird), macht es daraus „die Felder haben nicht die Form, die das Spiel schreibt“. Die Regel „beim Lesen fasse ich das Spiel nicht an“ macht das sicher: Welcher Fehler auch fällt, das Spiel ist dasselbe. Genau so ein Spielstand brachte die erste Version des Kanons zum Absturz.

null oder einen Text zurückzugeben ist der Stil des Kanons: Der Bildschirm schreibt das Ergebnis ins Menü. In anderem Code wäre dasselbe eine Ausnahme, die bis zum Bildschirm durchgeht.

Sieh es dir an

Speichern, Prüfsumme, Ablehnung

Hier sind Speichern und Laden Schritt für Schritt. Iva wird Zeile für Zeile geschrieben, und die Summe wächst mit jeder Zeile. Ein anderes Spiel (Boris) lädt den Spielstand: Die Prüfungen laufen über den Kopf, die Version und alle Zeilen, und die Summe kommt gleich heraus. Dann wird das Gold auf 99999 erhöht, und das Laden lehnt ab.

// Die Summe entsteht aus den Zeichen von allem darüber; ändert jemand nur einen Buchstaben, stimmt sie nicht mehr.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}
function sign(body) {
return body + "checksum=" + checksum(body) + "\n";
}
function saveGame(game) {
const hero = game.hero;
const lines = [];
lines.push("format=" + SAVE_FORMAT);
lines.push("version=" + SAVE_VERSION);
lines.push("turn=" + game.turn);
lines.push("hero.name=" + hero.name);
lines.push("hero.level=" + hero.level);
lines.push("hero.hp=" + hero.hp);
lines.push("hero.gold=" + hero.gold);
lines.push("hero.x=" + hero.x);
lines.push("hero.y=" + hero.y);
const bag = [];
for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count);
lines.push("hero.inventory=" + joinOrDash(bag));
return sign(lines.join("\n") + "\n");
}
function readValues(text) {
const lines = text.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("das ist kein Arkanum-Spielstand");
const values = new Map();
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf("=");
if (eq <= 0) throw new SaveError(`Zeile ${i + 1} hat nicht die Form Schlüssel=Wert`);
values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = need(values, "version");
if (version !== String(SAVE_VERSION)) throw new SaveError(`Version ${version}, aber das Spiel liest Version ${SAVE_VERSION}`);
const last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError(`checksum fehlt`);
const body = lines.slice(0, lines.length - 1).join("\n") + "\n";
const actual = checksum(body);
const stored = last.substring(9);
if (stored !== String(actual)) throw new SaveError(`die Prüfsumme stimmt nicht (gespeichert ${stored}, berechnet ${actual}), jemand hat die Datei verändert`);
return values;
}
// Gibt null zurück, wenn alles gut ging, sonst den Text des Problems.
function loadGame(game, text) {
try {
const values = readValues(text);
const hero = readHero(values);
const turn = needNumber(values, "turn");
// Alles ist gelesen und geprüft: erst jetzt wird das Spiel angefasst.
game.hero = hero;
game.turn = turn;
return null;
} catch (error) {
if (!(error instanceof SaveError)) throw error;
return `Der Spielstand ist kaputt: ${error.message}. Das Spiel geht ohne ihn weiter.`;
}
}
Schritt für Schritt, in Worten
  1. Ivas Spiel liegt im Speicher. Zum Speichern schreiben wir es als Text, Zeile für Zeile als Schlüssel=Wert. Oben stehen das Format und die Version, damit das Laden erkennt, ob es einen Spielstand dieses Spiels und dieser Version liest.
  2. Es folgen die Zeilen der Welt: Zug, Name, Stufe, Leben, Gold, Position und Beutel. Der Beutel ist eine Zeile (Gegenstand:Anzahl, mit Komma getrennt), ein leerer ist ein Strich. Jede Zeile ist eine Tatsache, die das Spiel braucht, um von dort weiterzumachen.
  3. Die letzte Zeile ist die Prüfsumme. Sie wächst mit jeder Zeile (die Zahl rechts): Die Summe wird mit 31 multipliziert und der Code jedes Zeichens addiert. Alles darüber ergibt 624337035; ändere ein Zeichen, und die Zahl ist eine andere.
  4. Ein anderes Spiel (Boris) lädt den Spielstand. Das Laden fasst das Spiel noch nicht an: Zuerst liest es. Es prüft den Kopf (Format), dass jede Zeile Schlüssel=Wert ist, und die Version.
  5. Die Summe wird aus denselben Zeilen neu berechnet (der Balken läuft darüber). Sie ergibt 624337035, so viel wie gespeichert: Die Datei ist so, wie sie geschrieben wurde.
  6. Erst jetzt, wo alles gelesen und geprüft ist, bekommt Boris’ Spiel den Helden aus dem Spielstand. Iva ist geladen: 42 Gold, Stufe 3.
  7. Jemand öffnet die Datei und erhöht das Gold: hero.gold=42 wird zu hero.gold=99999. Die Datei ist noch lesbarer Text, jede Zeile ist Schlüssel=Wert. Aber die Summe am Ende wurde für die alten Zeilen geschrieben.
  8. Das Laden berechnet die Summe erneut. Bis zum Gold sind die Zahlen gleich, von da an unterscheiden sie sich (orange), und am Ende kommt 748631855 heraus. Die gespeicherte ist 624337035: Sie stimmen nicht überein.
  9. Ablehnung: „Der Spielstand ist kaputt: die Prüfsumme stimmt nicht (gespeichert 624337035, berechnet 748631855), jemand hat die Datei verändert. Das Spiel geht ohne ihn weiter.“ Das Spiel bleibt, wie es ist: Iva mit 42 Gold. Die 99999 erreicht es nie.

Der Code neben der Szene ist checksum, sign, saveGame, readValues und loadGame aus dem Programm. In jedem Schritt leuchten die Zeilen, die in diesem Moment arbeiten.

Die Idee

Sechs kaputte Spielstände

Gelesen wird Ivas Spielstand, aber sechsmal zuvor war die Datei auf eine andere Weise kaputt. Das Programm schickt alle in Boris’ Spiel und druckt für jeden die Ablehnung. resign darin ist „jemand, der weiß, wie die Summe berechnet wird“:

// Entfernt die alte Summe und setzt eine neue: das ist jemand, der weiß, wie die Summe berechnet wird.
function resign(text) {
return sign(text.substring(0, text.lastIndexOf("checksum=")));
}
function heroLine(hero) {
return hero.name + ": Stufe " + hero.level + ", Leben " + hero.hp + ", Gold " + hero.gold;
}
const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const good = saveGame(iva);
const broken = [];
// Jemand hat das Gold von Hand erhöht: die Summe ist alt.
broken.push(good.replace("hero.gold=42", "hero.gold=99999"));
// Die Datei eines anderen Spiels.
broken.push(good.replace("format=ARCANUM", "format=SOLITAIRE"));
// Von einer neueren Version des Spiels geschrieben.
broken.push(good.replace("version=1", "version=2"));
// Eine Zeile fehlt, die Summe ist neu berechnet.
broken.push(resign(good.replace("hero.gold=42\n", "")));
// Eine schlechte Zahl, die Summe ist neu berechnet.
broken.push(resign(good.replace("hero.level=3", "hero.level=three")));
// Eine Zeile ohne Gleichheitszeichen.
broken.push(good.replace("hero.hp=27", "hero.hp 27"));
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
for (let i = 0; i < broken.length; i++) {
console.log((i + 1) + ": " + loadGame(game, broken[i]));
}
console.log(heroLine(game.hero));
// Der ganze Spielstand geht durch.
console.log(loadGame(game, good) === null ? "ok" : "no");
console.log(heroLine(game.hero));
Ausgabe
1: Der Spielstand ist kaputt: die Prüfsumme stimmt nicht (gespeichert 624337035, berechnet 748631855), jemand hat die Datei verändert. Das Spiel geht ohne ihn weiter.
2: Der Spielstand ist kaputt: das ist kein Arkanum-Spielstand. Das Spiel geht ohne ihn weiter.
3: Der Spielstand ist kaputt: Version 2, aber das Spiel liest Version 1. Das Spiel geht ohne ihn weiter.
4: Der Spielstand ist kaputt: hero.gold fehlt. Das Spiel geht ohne ihn weiter.
5: Der Spielstand ist kaputt: falscher Wert bei hero.level. Das Spiel geht ohne ihn weiter.
6: Der Spielstand ist kaputt: Zeile 6 hat nicht die Form Schlüssel=Wert. Das Spiel geht ohne ihn weiter.
Boris: Stufe 1, Leben 10, Gold 7
ok
Iva: Stufe 3, Leben 27, Gold 42

Jede Ablehnung gehört zu einer eigenen Prüfung:

Nr. Was wurde angefasst Welche Prüfung fängt es
1 Gold von Hand erhöht, Prüfsumme alt Prüfsumme: gespeicherte und berechnete unterscheiden sich
2 die Datei eines anderen Spiels Kopf: Die erste Zeile ist nicht format=ARCANUM
3 ein Spielstand von Version 2 Version: kommt vor der Prüfsumme
4 eine Zeile fehlt, Prüfsumme neu und richtig Inhalt: need findet hero.gold nicht
5 hero.level=three, Prüfsumme neu und richtig Inhalt: needNumber sieht keine Zahl
6 eine Zeile ohne Gleichheitszeichen Form der Zeile: schon beim Zerlegen

Der vierte und der fünfte kommen an der Prüfsumme vorbei, weil sie neu signiert sind: Darum hat das Laden eine zweite Schicht. Die Prüfsumme fängt Beschädigung, und need und needNumber fangen eine Datei, die ganz und falsch ist.

Am Ende ist Boris unberührt (Boris: Stufe 1 ...), bis der gute Spielstand hereinkommt: Dann ist er Iva. Keiner der sechs hat ihn verändert.

Die Idee

Im Spiel

Die kleine Welt ist dieselbe wie im Kanon, nur kleiner. Hier die echte Prüfsumme. Die Rechnung ist dieselbe; der Unterschied ist nur der Typ des Ergebnisses:

function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007;
return sum;
}

Und hier der Spielstand des ganzen Spiels. Außer unseren Zeilen gibt es seed, rng, ids und summary (die Zeile für das Slot-Menü: Name, Klasse, Stufe und Ort), und für jede der drei Ebenen die Zeilen der Karte, das Gesehene, die Dekoration, die Gegenstände, die Gegner mit ihren Effekten und die Leute:

function saveGame(game) {
const lines = [];
const put = (key, value) => lines.push(key + '=' + value);
const hero = game.hero;
put('format', SAVE_FORMAT);
put('version', SAVE_VERSION);
put('summary', t('slot.summary', { name: hero.name, heroClass: nm(hero.heroClass()), level: hero.level, place: placeName(game) }));
put('seed', game.seed);
put('rng', game.rng.state);
put('ids', game.ids.next);
put('turn', game.turn);
put('depth', game.depth);
put('victory', game.victory ? 1 : 0);
put('hero.name', hero.name);
put('hero.class', hero.classId);
put('hero.level', hero.level);
put('hero.xp', hero.xp);
put('hero.gold', hero.gold);
put('hero.hp', hero.hp);
put('hero.mana', hero.mana);
put('hero.x', hero.x);
put('hero.y', hero.y);
const base = [];
for (const stat of STAT_NAMES) base.push(hero.base.get(stat));
put('hero.base', base.join(','));
put('hero.skills', joinOrDash(hero.skills));
put('hero.cooldowns', cooldownsText(hero));
put('hero.effects', effectsText(hero));
const equipment = [];
for (const slot of hero.equipment.keys()) equipment.push(hero.equipment.get(slot) === '' ? '-' : hero.equipment.get(slot));
put('hero.equipment', equipment.join(','));
const inventory = [];
for (const slot of hero.inventory.slots) inventory.push(slot.id + ':' + slot.count);
put('hero.inventory', joinOrDash(inventory));
const stats = game.stats;
put('stats', [stats.turns, stats.kills, stats.blood, stats.dealt, stats.taken, stats.potions].join(','));
put('flags', joinOrDash(game.flags));
for (const quest of game.quests) put('quest.' + quest.id, quest.state + ':' + quest.stage);
for (const level of game.levels) {
const prefix = 'level.' + level.depth + '.';
for (let y = 0; y < level.height; y++) {
let seen = '';
let decor = '';
for (let x = 0; x < level.width; x++) {
seen += level.seen[y][x] ? '1' : '0';
decor += level.decor[y][x] === ' ' ? '_' : level.decor[y][x];
}
put(prefix + 'tiles.' + y, level.tiles[y].join(''));
put(prefix + 'seen.' + y, seen);
put(prefix + 'decor.' + y, decor);
}
const items = [];
for (const item of level.items) items.push(item.x + ':' + item.y + ':' + item.id + ':' + item.count);
put(prefix + 'items', joinOrDash(items));
put(prefix + 'enemies', level.enemies.length);
for (let i = 0; i < level.enemies.length; i++) {
const enemy = level.enemies[i];
put(prefix + 'enemy.' + i, [enemy.kind.id, enemy.x, enemy.y, enemy.hp, enemy.state, enemy.calm, enemy.lostTurns, enemy.phase].join(','));
put(prefix + 'enemy.' + i + '.effects', effectsText(enemy));
put(prefix + 'enemy.' + i + '.cooldowns', cooldownsText(enemy));
}
const npcs = [];
for (const npc of level.npcs) npcs.push(npc.id + ':' + npc.x + ':' + npc.y);
put(prefix + 'npcs', joinOrDash(npcs));
}
const body = lines.join('\n') + '\n';
return body + 'checksum=' + checksum(body) + '\n';
}

Das Lesen im Kanon ist eine Klasse SaveReader: Der Konstruktor macht dieselben Prüfungen wie unser readValues, und text, int, ints und list sind die Wächter. Hier ist sie:

class SaveReader {
constructor(text) {
this.values = new Map();
const lines = text.split('\n');
while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop();
if (lines.length === 0 || lines[0] !== 'format=' + SAVE_FORMAT) throw new SaveError(t('load.badHeader'));
for (let i = 0; i < lines.length; i++) {
const eq = lines[i].indexOf('=');
if (eq <= 0) throw new SaveError(t('load.badLine', { line: i + 1 }));
this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1));
}
const version = this.text('version');
if (version !== String(SAVE_VERSION)) throw new SaveError(t('load.badVersion', { found: version, expected: SAVE_VERSION }));
const last = lines[lines.length - 1];
if (!last.startsWith('checksum=')) throw new SaveError(t('load.missing', { key: 'checksum' }));
const body = lines.slice(0, lines.length - 1).join('\n') + '\n';
const actual = checksum(body);
if (parseIntStrict(last.substring(9)) !== actual) {
throw new SaveError(t('load.badChecksum', { expected: last.substring(9), actual: actual }));
}
}
text(key) {
if (!this.values.has(key)) throw new SaveError(t('load.missing', { key: key }));
return this.values.get(key);
}
int(key) {
const value = parseIntStrict(this.text(key));
if (value === null) throw new SaveError(t('load.badValue', { key: key }));
return value;
}
ints(key, count) {
const parts = this.text(key).split(',');
if (parts.length !== count) throw new SaveError(t('load.badValue', { key: key }));
const result = [];
for (const part of parts) {
const value = parseIntStrict(part);
if (value === null) throw new SaveError(t('load.badValue', { key: key }));
result.push(value);
}
return result;
}
list(key) {
return splitList(this.text(key));
}
}

Und die Hülle, die das Spiel schützt. Die Zuweisung steht nach dem Kommentar „Alles ist gelesen“, und catch macht aus jedem Fehler Text:

// Liefert null bei Erfolg oder den Fehlertext.
function loadGame(game, text) {
if (text === null) return t('load.empty');
try {
const reader = new SaveReader(text);
const ids = new IdSource(1);
const hero = readHero(reader, ids);
const levels = [];
for (const def of MAP_DEFS) levels.push(readLevel(reader, def, ids));
const quests = createQuests();
for (const quest of quests) {
const pieces = reader.text('quest.' + quest.id).split(':');
if (pieces.length !== 2) throw new SaveError(t('load.badValue', { key: 'quest.' + quest.id }));
quest.state = pieces[0];
quest.stage = toInt(pieces[1]);
}
const stats = reader.ints('stats', 6);
const depth = reader.int('depth');
if (depth < 0 || depth > levels.length) throw new SaveError(t('load.badValue', { key: 'depth' }));
// Alles ist gelesen: erst jetzt wird das Spiel angefasst.
game.hero = hero;
game.levels = levels;
game.useQuests(quests);
game.flags = reader.list('flags');
game.stats = { turns: stats[0], kills: stats[1], blood: stats[2], dealt: stats[3], taken: stats[4], potions: stats[5] };
game.depth = depth;
game.turn = reader.int('turn');
game.victory = reader.int('victory') === 1;
game.rng.state = reader.int('rng') >>> 0;
game.ids = new IdSource(Math.max(reader.int('ids'), ids.next));
game.messages = [];
if (depth > 0) computeFov(game.level(), hero.x, hero.y);
return null;
} catch (error) {
// Bis zu „erst jetzt wird das Spiel angefasst“ liest der Code nur: Welcher Fehler auch kommt, er kommt aus den Daten.
const reason = error instanceof SaveError ? error.message : t('load.badData');
return t('load.broken', { reason: reason });
}
}

Was das Spiel sonst noch um diesen Code hat:

  • Slots. Das Menü schreibt in Slot 1, 2 oder 3 über io.save(slot, text) und liest über io.load(slot). In Node sind das Dateien in einem Ordner saves, im Browser ein Eintrag in localStorage. Der Spielstand selbst weiß nicht, wohin er geht.
  • Ein kaputter Slot zum Testen. Mit --memory-saves liegen die Spielstände im Speicher, und main legt den Spielstand aus tamperedSave() in Slot 3, in der Sprache des Spiels: ein Spielstand mit hero.gold=99999 und einer Prüfsumme, die nicht zu diesem Text gehört. So zeigt der aufgezeichnete Durchlauf des Spiels die Ablehnung: Das Menü sagt „Der Spielstand ist kaputt“, und das Spiel geht weiter.
  • Die Zusammenfassung des Slots. slotSummary liest nur die Zeile summary=, ohne die Welt zu laden: So zeigt das Menü für jeden Slot „Iva, Barbar, St.4, Graufurt“.
  • Nach dem Laden. loadGame leert die Meldungen und berechnet das Sichtbare mit computeFov neu, und enterWorld sagt „Aus Slot 1 geladen“ und ersetzt den Stapel durch den Bildschirm der Stadt oder der Karte (siehe die Lektion über den Bildschirmstapel).

Probier es aus

Finde den Fehler

Ein Kollege fand es einfacher, die Werte beim Lesen gleich ins Spiel zu schreiben. Gegeben ist ein Spielstand mit richtiger Prüfsumme, aber mit hero.level=three:

const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.hero.inventory.push(new Slot("potion_heal", 2));
iva.hero.inventory.push(new Slot("leather", 1));
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
const text = resign(saveGame(iva).replace("hero.level=3", "hero.level=three"));
console.log(loadGame(game, text));
console.log(heroLine(game.hero));
Ausgabe
Der Spielstand ist kaputt: falscher Wert bei hero.level. Das Spiel geht ohne ihn weiter.
Iva: Stufe 1, Leben 10, Gold 7

Das Laden lehnt ab, wie es soll, aber das Spiel ist nicht dasselbe: Der Name ist Iva, während Stufe, Leben und Gold von Boris sind. Welche Zeile fasst das Spiel als erste an, bevor der Spielstand zu Ende gelesen ist?

Wähle die Zeile mit dem Fehler.

Genauer

Genauer: Prüfsummen, Versionen und was sonst schiefgehen kann

Eine Prüfsumme ist keine Signatur. Unsere mischt Zeichen mit einem Modulus; es gibt fertige und stärkere: CRC32 fängt Schäden von Platten und Netzen zuverlässiger, und bei SHA-256 ist es fast unmöglich, den Inhalt unbemerkt auszutauschen. Aber auch sie schützen nicht vor einem Menschen, der den Algorithmus kennt und eine neue richtige Prüfsumme schreibt: resign im Programm tut genau das. Nur eine Signatur mit geheimem Schlüssel (HMAC) würde es fangen, und in einem Einzelspieler-Spiel auf dem eigenen Rechner des Spielers gibt es keinen geheimen Schlüssel und keinen Sinn in solchem Schutz. Die Prüfsumme hier ist für Beschädigung und ungeschicktes Ändern. Wer in einem Einzelspieler-Spiel schummeln will, tut es, und das ist seine Sache.

Versionen und Migrationen. Jede Änderung am Format ist eine neue Version. Drei Wege: ablehnen (unser Laden: „Version 2, aber das Spiel liest Version 1“), auch die alten Versionen mit eingebauter Umwandlung lesen (die Aufgabe unten) oder alle für immer lesen. Das Letzte wächst: Nach Jahren steckt im Laden Code für ein Dutzend Versionen. Darum baut man oft eine Kette von „Migrationen“: Version 1 → 2 → 3, jeder Schritt klein und mit einem Test.

Ein Schreibvorgang, der auf halbem Weg abbricht. Der Strom fällt aus, während die Datei geschrieben wird: Auf der Platte bleibt eine halbe Datei. Die Prüfsumme fängt sie (die letzte Zeile fehlt, „checksum fehlt“), aber der Spielstand des Spielers ist trotzdem verloren. Besser ist es, in eine temporäre Datei zu schreiben und sie erst, wenn sie ganz geschrieben ist, über die alte umzubenennen; ein Umbenennen ist eine einzige Operation. Unser io.save schreibt direkt, weil die Lektion nicht von Dateisystemen handelt.

Warum nicht JSON. JSON täte seinen Dienst, und du wirst es in anderen Lektionen sehen. Hier ist der Text Schlüssel=Wert absichtlich einfach: Die Ausgabe muss in vier Sprachen ohne Bibliotheken gleich sein, und das ganze Format lässt sich auf einem Bildschirm lesen. Der Preis ist, dass verschachtelte Dinge (ein Beutel, ein Gegner mit Effekten) von Hand mit : und , zusammengeklebt werden.

Ein Binärformat. Kleiner und schneller, aber nicht mit den Augen les- oder vergleichbar. Für ein Textspiel mit Spielständen von ein paar Kilobyte ist es unnötig.

Übung

Version 2: Mana

Löse die Aufgabe in deinem Editor. Beginne mit dem Programm für das Laden.

Mana und zwei Versionen. Der Held bekommt Mana (mana, standardmäßig 10). Version 2 des Formats fügt eine Zeile hero.mana gleich nach hero.hp hinzu. Das Spiel schreibt jetzt Version 2, liest aber noch Spielstände von Version 1: Sie haben kein Mana, und der Held bekommt den Standardwert. Version 3 (und höher) wird mit derselben Meldung wie bisher abgelehnt, und readValues akzeptiert eine Version zwischen 1 und der neuesten. Sorge dafür, dass loadGame für beide Arten von Spielstand null zurückgibt.

Hier der Spielstand von Version 1 (so, wie ihn das Programm der Lektion schreibt), die Hilfsfunktion und der Rumpf:

// Ein Spielstand, wie ihn Version 1 des Spiels schreibt.
const V1_SAVE = [
"format=ARCANUM",
"version=1",
"turn=212",
"hero.name=Iva",
"hero.level=3",
"hero.hp=27",
"hero.gold=42",
"hero.x=5",
"hero.y=2",
"hero.inventory=potion_heal:2,leather:1",
"checksum=624337035",
].join("\n") + "\n";
function check(game, number, text) {
const error = loadGame(game, text);
console.log(number + ": " + (error === null ? "ok" : error));
console.log(heroLine(game.hero));
}
const source = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
source.hero.inventory.push(new Slot("potion_heal", 2));
source.hero.inventory.push(new Slot("leather", 1));
source.hero.mana = 8;
const v2 = saveGame(source);
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
check(game, 1, V1_SAVE);
check(game, 2, v2);
check(game, 3, v2.replace("version=2", "version=3"));

Die Ausgabe ist:

Ausgabe
1: ok
Iva: Stufe 3, Leben 27, Gold 42, Mana 10
2: ok
Iva: Stufe 3, Leben 27, Gold 42, Mana 8
3: Der Spielstand ist kaputt: Version 3, aber das Spiel liest Version 2. Das Spiel geht ohne ihn weiter.
Iva: Stufe 3, Leben 27, Gold 42, Mana 8

Weitere Aufgaben

  • Automatisches Speichern. Schreibe alle 100 Züge in einen eigenen Slot auto. Woran erkennt das Menü, dass der Slot automatisch ist?
  • Eine abgeschnittene Datei. Entferne die letzte Zeile eines Spielstands (die Prüfsumme). Was sagt das Laden? Ändere io.save so, dass es in eine temporäre Datei schreibt und sie umbenennt.
  • Die Zusammenfassung des Slots. Füge eine Zeile summary= mit Name und Stufe hinzu und eine Funktion, die sie liest, ohne die Welt zu laden, wie slotSummary im Kanon.
  • Derselbe Schlüssel zweimal. Jetzt gewinnt das letzte hero.gold= stillschweigend, weil die Tabelle ersetzt. Lass readValues Spielstände mit einem wiederholten Schlüssel ablehnen.

Test

Prüfe dich

1. Ein Spielstand ist von Version 2 und seine Prüfsumme stimmt nicht, aber du lädst ihn in ein Spiel, das Version 1 liest. Welchen Fehler sieht der Spieler, und warum?
2. Die Prüfsumme von „ab“ ist 3105 und die von „ba“ ist 3135. Warum ist sie nicht einfach die Summe der Zeichencodes (195 für beide Texte)?
3. Jemand, der weiß, wie die Prüfsumme berechnet wird, erhöht das Gold auf 99999 und schreibt die Prüfsumme des neuen Textes ans Ende. Was passiert beim Laden?
4. Das Laden liest alles in lokale Variablen und setzt sie erst danach ins Spiel. Warum?
5. Der Kanon speichert auch den Zustand des Zufallsgenerators (rng). Warum?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·