Към съдържанието
wannacode

Запис и зареждане

Накратко: Записът е светът като текст „ключ=стойност“ с версия и контролна сума най-отдолу; зареждането чете и проверява всичко в локални променливи и пипа играта чак накрая, затова развален запис се отказва, без да я повреди.

Проблем

Играта, която забравя всичко

Играч е прекарал два часа в Сивия брод: взел е броня, убил е стража на мината, събрал е злато. Затваря терминала. На сутринта играта започва отначало и той не се връща. Запис и зареждане са това, което превръща играта от сесия в пътуване.

Задачата изглежда проста („запиши променливите във файл“), но стоят три въпроса:

  1. Какво се записва? Не кодът и не таблиците (предмети, врагове, умения: те са в играта). Записва се само това, което играта е променила: героят, ходът, картата, враговете, куестовете. Останалото се строи наново.
  2. Откъде зареждането знае, че файлът е добър? Файлът може да е на друга игра, от по-стара или по-нова версия, откъснат по средата или пипнат на ръка.
  3. Какво прави, когато не е добър? Не се срутва и не остава наполовина заредена.

Урокът строи малък свят (герой, торба, ход) и прекарва през него и трите въпроса: записът като текст, версия и контролна сума, зареждане, което първо чете и проверява, и шест развалени записа. После показва същото в истинската игра.

Идеята

Светът като текст: ключ=стойност

Записът е обикновен текст, ред по ред: ключ, знак за равно, стойност. Един ред е един факт. Така файлът се чете с очи, сравнява се с друг запис в редактор и не иска библиотека. Ето типовете на малкия свят: герой с торба, игра и грешка при записа.

record Slot(string Id, int Count);
class Hero
{
public string Name;
public int Level;
public int Hp;
public int Gold;
public int X;
public int Y;
public List<Slot> Inventory = new List<Slot>();
public Hero(string name, int level, int hp, int gold, int x, int y)
{
Name = name;
Level = level;
Hp = hp;
Gold = gold;
X = x;
Y = y;
}
}
class Game
{
public Hero Hero;
public int Turn;
public Game(Hero hero, int turn)
{
Hero = hero;
Turn = turn;
}
}
class SaveError : Exception
{
public SaveError(string message) : base(message) { }
}

А ето записът. Най-отгоре са форматът и версията, после светът, а най-отдолу е контролната сума (за нея след малко):

const string SAVE_FORMAT = "ARCANUM";
const int SAVE_VERSION = 1;
// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
int Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007;
return (int)sum;
}
// Празният списък се пише като тире: така редът не остава с празна стойност.
string JoinOrDash(List<string> parts)
{
return parts.Count == 0 ? "-" : string.Join(",", parts);
}
string Sign(string body)
{
return body + "checksum=" + Checksum(body) + "\n";
}
string SaveGame(Game game)
{
Hero hero = game.Hero;
var lines = new List<string>();
lines.Add("format=" + SAVE_FORMAT);
lines.Add("version=" + SAVE_VERSION);
lines.Add("turn=" + game.Turn);
lines.Add("hero.name=" + hero.Name);
lines.Add("hero.level=" + hero.Level);
lines.Add("hero.hp=" + hero.Hp);
lines.Add("hero.gold=" + hero.Gold);
lines.Add("hero.x=" + hero.X);
lines.Add("hero.y=" + hero.Y);
var bag = new List<string>();
foreach (Slot slot in hero.Inventory) bag.Add(slot.Id + ":" + slot.Count);
lines.Add("hero.inventory=" + JoinOrDash(bag));
return Sign(string.Join("\n", lines) + "\n");
}

Торбата е един ред: предмет:брой, разделени със запетая. Празната торба се пише като тире: ред hero.inventory= с празна стойност би изглеждал като липсваща. Всеки ред се прави с ключ=стойност, всичките се слепват с нов ред, и накрая sign слага сумата. Програмата записва Ива и после същата игра с празна торба (само двата реда, които се променят):

var game = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
game.Hero.Inventory.Add(new Slot("potion_heal", 2));
game.Hero.Inventory.Add(new Slot("leather", 1));
Console.Write(SaveGame(game));
game.Hero.Inventory.Clear();
string[] again = SaveGame(game).Split('\n');
Console.WriteLine(again[9]);
Console.WriteLine(again[10]);
Изход
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

Втората сума е съвсем друга, макар да е променен един ред: това е целта ѝ.

По-точно, за любопитните

Какво канонът записва отгоре на нашия свят: семето и състоянието на генератора (seed, rng, ids), така че след зареждане зарчетата да следват същата редица; нивата (три карти, ред по ред, с видяното и украсата); враговете с живот, състояние и ефекти; куестовете и флаговете. Това не са таблиците с предмети и врагове: те са в играта и записът пази само id (potion_heal:2), а не целия предмет.

Какво не се записва, защото се смята наново: показателите със слоевете им (база, екипировка, ефекти), полето на видимост (след зареждане играта вика computeFov) и всичко, което се строи от таблиците. По-малко в записа значи по-малко места за грешка.

Идеята

Версия и контролна сума

Два реда пазят записа от неприятни изненади.

Версията (version=1) казва кой вариант на формата е писал файла. Играта ще се промени: ще има мана, ще има нови редове. Старата игра, която прочете нов запис, не бива да гадае; и новата, която прочете стар, трябва или да знае как, или да откаже ясно. Затова версията е втори ред и се проверява преди всичко друго, освен заглавието.

Контролната сума (checksum=...) е едно число от всички редове над нея. Ето я цялата:

// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
int Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007;
return (int)sum;
}

Върви по знаците на текста: сумата се умножава по 31 и се добавя кодът на знака, а остатъкът от 1000000007 я пази малка. Умножението е важно. Ако само събираше кодовете, „ab“ и „ba“ щяха да са едно число. Ето какво дава това:

// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
int Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007;
return (int)sum;
}
Console.WriteLine(Checksum("ab"));
Console.WriteLine(Checksum("ba"));
Отговор

Записът е готов, когато сумата на всичко над последния ред е написана най-отдолу. Който промени един знак, ще види сумата да не съвпада.

В Checksum сметката е в long: sum * 31 с int би препълнило преди остатъка. Резултатът е под 1000000007 и се връща като int.
// Празният списък се пише като тире: така редът не остава с празна стойност.
string JoinOrDash(List<string> parts)
{
    return parts.Count == 0 ? "" : string.Join(",", parts);
}

Идеята

Зареждане: първо четеш, после пипаш

Зареждането е огледалото на записа и има едно правило: докато не е прочетено и проверено всичко, играта не се пипа. Всичко се чете в локални променливи, а присвояването към играта е последното нещо в метода.

Първо проверките. readValues взема текста и връща таблица ключ → стойност, а при всяко нередно нещо хвърля SaveError със съобщението за играча:

// Само цифри, най-много девет.
bool IsNumber(string text)
{
if (text.Length == 0 || text.Length > 9) return false;
foreach (char c in text)
{
if (c < '0' || c > '9') return false;
}
return true;
}
string Need(Dictionary<string, string> values, string key)
{
if (!values.ContainsKey(key)) throw new SaveError("липсва " + key);
return values[key];
}
int NeedNumber(Dictionary<string, string> values, string key)
{
string value = Need(values, key);
if (!IsNumber(value)) throw new SaveError("грешна стойност на " + key);
return int.Parse(value);
}
Dictionary<string, string> ReadValues(string text)
{
var lines = new List<string>(text.Split('\n'));
if (lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1);
if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError("това не е запис на Арканум");
var values = new Dictionary<string, string>();
for (int i = 0; i < lines.Count; i++)
{
int eq = lines[i].IndexOf('=');
if (eq <= 0) throw new SaveError("ред " + (i + 1) + " не е във вида ключ=стойност");
values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1);
}
string version = Need(values, "version");
if (version != SAVE_VERSION.ToString()) throw new SaveError("версия " + version + ", а играта чете версия " + SAVE_VERSION);
string last = lines[lines.Count - 1];
if (!last.StartsWith("checksum=")) throw new SaveError("липсва checksum");
string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n";
int actual = Checksum(body);
string stored = last.Substring(9);
if (stored != actual.ToString()) throw new SaveError("контролната сума не съвпада (записана " + stored + ", изчислена " + actual + "), някой е пипал файла");
return values;
}

Редът е важен: заглавие (format=ARCANUM), всеки ред да е ключ=стойност, версията, сумата. Сумата се смята от всичко над последния ред и се сравнява със записаната там; need и needNumber са стражите на ключовете (липсва ли, и числото число ли е).

После героят и самото зареждане:

Hero ReadHero(Dictionary<string, string> values)
{
string name = Need(values, "hero.name");
int level = NeedNumber(values, "hero.level");
int hp = NeedNumber(values, "hero.hp");
int gold = NeedNumber(values, "hero.gold");
int x = NeedNumber(values, "hero.x");
int y = NeedNumber(values, "hero.y");
var hero = new Hero(name, level, hp, gold, x, y);
string bag = Need(values, "hero.inventory");
if (bag != "-")
{
foreach (string part in bag.Split(','))
{
string[] pieces = part.Split(':');
if (pieces.Length != 2 || !IsNumber(pieces[1])) throw new SaveError("грешна стойност на hero.inventory");
hero.Inventory.Add(new Slot(pieces[0], int.Parse(pieces[1])));
}
}
return hero;
}
// Връща null, ако всичко е минало, иначе текста на проблема.
string LoadGame(Game game, string text)
{
try
{
var values = ReadValues(text);
Hero hero = ReadHero(values);
int turn = NeedNumber(values, "turn");
// Всичко е прочетено и проверено: чак сега се пипа играта.
game.Hero = hero;
game.Turn = turn;
return null;
}
catch (SaveError error)
{
return "Записът е развален: " + error.Message + ". Играта продължава без него.";
}
}

readHero събира героя от таблицата; loadGame е кратка обвивка: чете героя и хода и чак тогава ги присвоява. SaveError се хваща и става текст; това, което тръгва, е играта без промяна. Резултатът е null при успех или съобщение за играча. Ето запис и зареждане в друга игра:

var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.Hero.Inventory.Add(new Slot("potion_heal", 2));
iva.Hero.Inventory.Add(new Slot("leather", 1));
string text = SaveGame(iva);
var copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
string error = LoadGame(copy, text);
Console.WriteLine(error == null ? "ok" : error);
Console.WriteLine(HeroLine(copy.Hero));
Console.WriteLine(SaveGame(copy) == text ? "Записът, написан наново, е същият." : "Записът, написан наново, е различен.");
Изход
ok
Iva: ниво 3, живот 27, злато 42
Записът, написан наново, е същият.

Записът на заредената игра е същият текст като първия: затова записът и зареждането са огледални. Борис е изчезнал, Ива е в играта.

int.Parse приема и „ +7“, затова IsNumber проверява сам, че са само цифри (най-много девет, да се побират в int). text.Split('\n') оставя празен последен елемент след завършващия нов ред; ReadValues го маха. Функциите са локални в програмата и си делят константите над тях.
По-точно, за любопитните

Каноничният loadGame прави още нещо: ако при четене падне грешка, която не е SaveError (например ръчно развален запис с вярна сума и число с неочакван вид в ред, който се чете с toInt), я превръща в „полетата не са във вида, който играта пише“. Правилото „докато чета, не пипам играта“ прави това безопасно: каквато и грешка да падне, играта е същата. Именно такъв запис срутваше първата версия на канона.

Връщането на null или текст е стил на канона: екранът пише резултата в менюто. В друг код същото би било изключение, което стига до екрана.

Виж го

Запис, сума, отказ

Ето записа и зареждането по ходове. Ива се пише ред по ред, сумата расте с всеки ред. Друга игра (Борис) зарежда записа: проверките минават през заглавието, версията и всичките редове и сумата се получава същата. После златото е вдигнато на 99999 и зареждането отказва.

// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
int Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007;
return (int)sum;
}
string Sign(string body)
{
return body + "checksum=" + Checksum(body) + "\n";
}
string SaveGame(Game game)
{
Hero hero = game.Hero;
var lines = new List<string>();
lines.Add("format=" + SAVE_FORMAT);
lines.Add("version=" + SAVE_VERSION);
lines.Add("turn=" + game.Turn);
lines.Add("hero.name=" + hero.Name);
lines.Add("hero.level=" + hero.Level);
lines.Add("hero.hp=" + hero.Hp);
lines.Add("hero.gold=" + hero.Gold);
lines.Add("hero.x=" + hero.X);
lines.Add("hero.y=" + hero.Y);
var bag = new List<string>();
foreach (Slot slot in hero.Inventory) bag.Add(slot.Id + ":" + slot.Count);
lines.Add("hero.inventory=" + JoinOrDash(bag));
return Sign(string.Join("\n", lines) + "\n");
}
Dictionary<string, string> ReadValues(string text)
{
var lines = new List<string>(text.Split('\n'));
if (lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1);
if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError("това не е запис на Арканум");
var values = new Dictionary<string, string>();
for (int i = 0; i < lines.Count; i++)
{
int eq = lines[i].IndexOf('=');
if (eq <= 0) throw new SaveError("ред " + (i + 1) + " не е във вида ключ=стойност");
values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1);
}
string version = Need(values, "version");
if (version != SAVE_VERSION.ToString()) throw new SaveError("версия " + version + ", а играта чете версия " + SAVE_VERSION);
string last = lines[lines.Count - 1];
if (!last.StartsWith("checksum=")) throw new SaveError("липсва checksum");
string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n";
int actual = Checksum(body);
string stored = last.Substring(9);
if (stored != actual.ToString()) throw new SaveError("контролната сума не съвпада (записана " + stored + ", изчислена " + actual + "), някой е пипал файла");
return values;
}
// Връща null, ако всичко е минало, иначе текста на проблема.
string LoadGame(Game game, string text)
{
try
{
var values = ReadValues(text);
Hero hero = ReadHero(values);
int turn = NeedNumber(values, "turn");
// Всичко е прочетено и проверено: чак сега се пипа играта.
game.Hero = hero;
game.Turn = turn;
return null;
}
catch (SaveError error)
{
return "Записът е развален: " + error.Message + ". Играта продължава без него.";
}
}
Стъпка по стъпка, с думи
  1. Играта на Ива е в паметта. За записа я пишем като текст, ред по ред „ключ=стойност“. Най-отгоре са форматът и версията: така зареждането знае дали чете запис на тази игра и на тази версия.
  2. Следват редовете на света: ход, име, ниво, живот, злато, място и торба. Торбата е един ред (предмет:брой), празната е „-“. Всеки ред е едно нещо, което играта трябва да знае, за да продължи оттам.
  3. Последният ред е контролната сума. Тя расте с всеки ред (числото вдясно): сумата се умножава по 31 и се добавя кодът на всеки знак. Всичко над нея дава 624337035; промени един знак и числото е друго.
  4. Друга игра (Борис) зарежда записа. Зареждането още не пипа играта: първо чете. Проверява заглавието (формат), всеки ред да е „ключ=стойност“ и версията.
  5. Сумата се смята наново от същите редове (лентата минава през тях). Получава се 624337035, колкото е записано: файлът е такъв, какъвто е писан.
  6. Чак сега, когато всичко е прочетено и проверено, играта на Борис получава героя от записа. Ива е заредена: 42 злато, ниво 3.
  7. Някой отваря файла и вдига златото: hero.gold=42 става hero.gold=99999. Файлът още е четим текст, всеки ред е „ключ=стойност“. Но сумата в края е написана за старите редове.
  8. Зареждането пак смята сумата. До златото числата са същите, оттам са други (в оранжево) и накрая се получава 748631855. Записаната е 624337035: не съвпадат.
  9. Отказ: „Записът е развален: контролната сума не съвпада (записана 624337035, изчислена 748631855), някой е пипал файла. Играта продължава без него.“ Играта остава каквато е: Ива с 42 злато. 99999 не стига до нея.

Кодът до сцената е checksum, sign, saveGame, readValues и loadGame от програмата. На всяка стъпка светят редовете, които работят в този момент.

Идеята

Шест развалени записа

Чете се записът на Ива, но шест пъти преди това файлът е развален по различен начин. Програмата пуска всичките в играта на Борис и печата отказа за всеки. resign в нея е „някой, който знае как се смята сумата“:

// Маха старата сума и слага нова: това е „някой, който знае как се смята сумата“.
string Resign(string text)
{
return Sign(text.Substring(0, text.LastIndexOf("checksum=")));
}
string HeroLine(Hero hero)
{
return hero.Name + ": ниво " + hero.Level + ", живот " + hero.Hp + ", злато " + hero.Gold;
}
var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.Hero.Inventory.Add(new Slot("potion_heal", 2));
iva.Hero.Inventory.Add(new Slot("leather", 1));
string good = SaveGame(iva);
var broken = new List<string>();
// Някой е вдигнал златото на ръка: сумата е стара.
broken.Add(good.Replace("hero.gold=42", "hero.gold=99999"));
// Файл на друга игра.
broken.Add(good.Replace("format=ARCANUM", "format=SOLITAIRE"));
// Записан от по-нова версия на играта.
broken.Add(good.Replace("version=1", "version=2"));
// Липсва ред, сумата е пресметната наново.
broken.Add(Resign(good.Replace("hero.gold=42\n", "")));
// Лошо число, сумата е пресметната наново.
broken.Add(Resign(good.Replace("hero.level=3", "hero.level=three")));
// Ред без знак за равно.
broken.Add(good.Replace("hero.hp=27", "hero.hp 27"));
var game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
for (int i = 0; i < broken.Count; i++)
{
Console.WriteLine((i + 1) + ": " + LoadGame(game, broken[i]));
}
Console.WriteLine(HeroLine(game.Hero));
// Целият запис влиза.
Console.WriteLine(LoadGame(game, good) == null ? "ok" : "no");
Console.WriteLine(HeroLine(game.Hero));
Изход
1: Записът е развален: контролната сума не съвпада (записана 624337035, изчислена 748631855), някой е пипал файла. Играта продължава без него.
2: Записът е развален: това не е запис на Арканум. Играта продължава без него.
3: Записът е развален: версия 2, а играта чете версия 1. Играта продължава без него.
4: Записът е развален: липсва hero.gold. Играта продължава без него.
5: Записът е развален: грешна стойност на hero.level. Играта продължава без него.
6: Записът е развален: ред 6 не е във вида ключ=стойност. Играта продължава без него.
Boris: ниво 1, живот 10, злато 7
ok
Iva: ниво 3, живот 27, злато 42

Всеки отказ е на собствена проверка:

№ Какво е пипнато Коя проверка го хваща
1 златото вдигнато на ръка, сумата е стара сума: записаната и изчислената се различават
2 файл на друга игра заглавие: първият ред не е format=ARCANUM
3 запис от версия 2 версия: минава преди сумата
4 липсва ред, сумата е наново и вярна съдържание: need не намира hero.gold
5 hero.level=three, сумата е наново и вярна съдържание: needNumber не вижда число
6 ред без знак за равно форма на реда: още при разчитането

Четвъртият и петият минават покрай сумата, защото са подписани наново: затова зареждането има и втори слой. Сумата хваща повреда, а need и needNumber хващат файл, който е цял и грешен.

Накрая Борис е непокътнат (Boris: ниво 1 ...), докато не влезе здравият запис: тогава е Ива. Нито един от шестте не го е променил.

Идеята

В играта

Малкият свят е същият като в канона, само по-малък. Ето истинската контролна сума. Смятането е същото; разликата е само в типа на резултата:

public static long Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007;
return sum;
}

А ето записът на цялата игра. Освен нашите редове има seed, rng, ids и summary (редът за менюто със слотове: име, клас, ниво и място), а за всяко от трите нива редовете на картата, видяното, украсата, предметите, враговете с ефектите им и хората:

public static string SaveGame(Game game)
{
List<string> lines = new List<string>();
Action<string, object> put = (key, value) => lines.Add(key + "=" + value);
Hero 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);
List<int> baseStats = new List<int>();
foreach (string stat in STAT_NAMES) baseStats.Add(hero.Base[stat]);
put("hero.base", string.Join(",", baseStats));
put("hero.skills", JoinOrDash(hero.Skills));
put("hero.cooldowns", CooldownsText(hero));
put("hero.effects", EffectsText(hero));
List<string> equipment = new List<string>();
foreach (string slot in hero.Equipment.Keys) equipment.Add(hero.Equipment.Get(slot) == "" ? "-" : hero.Equipment.Get(slot));
put("hero.equipment", string.Join(",", equipment));
List<string> inventory = new List<string>();
foreach (ItemStack slot in hero.Inventory.Slots) inventory.Add(slot.Id + ":" + slot.Count);
put("hero.inventory", JoinOrDash(inventory));
Stats stats = game.Stats;
put("stats", string.Join(",", new int[] { stats.Turns, stats.Kills, stats.Blood, stats.Dealt, stats.Taken, stats.Potions }));
put("flags", JoinOrDash(game.Flags));
foreach (Quest quest in game.Quests) put("quest." + quest.Id, quest.State + ":" + quest.Stage);
foreach (Level level in game.Levels)
{
string prefix = "level." + level.Depth + ".";
for (int y = 0; y < level.Height; y++)
{
string seen = "";
string decor = "";
for (int 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, new string(level.Tiles[y]));
put(prefix + "seen." + y, seen);
put(prefix + "decor." + y, decor);
}
List<string> items = new List<string>();
foreach (FloorItem item in level.Items) items.Add(item.X + ":" + item.Y + ":" + item.Id + ":" + item.Count);
put(prefix + "items", JoinOrDash(items));
put(prefix + "enemies", level.Enemies.Count);
for (int i = 0; i < level.Enemies.Count; i++)
{
Enemy enemy = level.Enemies[i];
put(prefix + "enemy." + i, string.Join(",", new object[] { enemy.Kind.Id, enemy.X, enemy.Y, enemy.Hp, enemy.State, enemy.Calm, enemy.LostTurns, enemy.Phase }));
put(prefix + "enemy." + i + ".effects", EffectsText(enemy));
put(prefix + "enemy." + i + ".cooldowns", CooldownsText(enemy));
}
List<string> npcs = new List<string>();
foreach (Npc npc in level.Npcs) npcs.Add(npc.Id + ":" + npc.X + ":" + npc.Y);
put(prefix + "npcs", JoinOrDash(npcs));
}
string body = string.Join("\n", lines) + "\n";
return body + "checksum=" + Checksum(body) + "\n";
}

Четенето в канона е клас SaveReader: конструкторът прави същите проверки като нашия readValues, а text, int, ints и list са стражите. Ето го:

class SaveReader
{
public Dictionary<string, string> Values;
public SaveReader(string text)
{
Values = new Dictionary<string, string>();
List<string> lines = new List<string>(text.Split('\n'));
while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1);
if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError(T("load.badHeader"));
for (int i = 0; i < lines.Count; i++)
{
int eq = lines[i].IndexOf('=');
if (eq <= 0) throw new SaveError(T("load.badLine", ("line", i + 1)));
Values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1);
}
string version = Text("version");
if (version != SAVE_VERSION.ToString()) throw new SaveError(T("load.badVersion", ("found", version), ("expected", SAVE_VERSION)));
string last = lines[lines.Count - 1];
if (!last.StartsWith("checksum=", StringComparison.Ordinal)) throw new SaveError(T("load.missing", ("key", "checksum")));
string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n";
long actual = Checksum(body);
if (ParseIntStrict(last.Substring(9)) != actual)
{
throw new SaveError(T("load.badChecksum", ("expected", last.Substring(9)), ("actual", actual)));
}
}
public string Text(string key)
{
if (!Values.ContainsKey(key)) throw new SaveError(T("load.missing", ("key", key)));
return Values[key];
}
// Десет цифри не се побират в int: стойността се отрязва до 32 бита, както при >>> 0.
public int Int(string key)
{
long? value = ParseIntStrict(Text(key));
if (value == null) throw new SaveError(T("load.badValue", ("key", key)));
return unchecked((int)value.Value);
}
public List<int> Ints(string key, int count)
{
string[] parts = Text(key).Split(',');
if (parts.Length != count) throw new SaveError(T("load.badValue", ("key", key)));
List<int> result = new List<int>();
foreach (string part in parts)
{
long? value = ParseIntStrict(part);
if (value == null) throw new SaveError(T("load.badValue", ("key", key)));
result.Add(unchecked((int)value.Value));
}
return result;
}
public List<string> List(string key)
{
return SplitList(Text(key));
}
}

И обвивката, която пази играта. Присвояването е след коментара „Всичко е прочетено“, а catch превръща всяка грешка в текст:

// Връща null при успех или текста на грешката.
public static string? LoadGame(Game game, string? text)
{
if (text == null) return T("load.empty");
try
{
SaveReader reader = new SaveReader(text);
IdSource ids = new IdSource(1);
Hero hero = ReadHero(reader, ids);
List<Level> levels = new List<Level>();
foreach (MapDef def in MAP_DEFS) levels.Add(ReadLevel(reader, def, ids));
List<Quest> quests = CreateQuests();
foreach (Quest quest in quests)
{
string[] 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]);
}
List<int> stats = reader.Ints("stats", 6);
int depth = reader.Int("depth");
if (depth < 0 || depth > levels.Count) throw new SaveError(T("load.badValue", ("key", "depth")));
// Всичко е прочетено: чак сега се пипа играта.
game.Hero = hero;
game.Levels = levels;
game.UseQuests(quests);
game.Flags = reader.List("flags");
game.Stats = new 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 = unchecked((uint)reader.Int("rng"));
game.Ids = new IdSource(Math.Max(reader.Int("ids"), ids.Next));
game.Messages = new List<string>();
if (depth > 0) ComputeFov(game.Level()!, hero.X, hero.Y);
return null;
}
catch (SaveError error)
{
return T("load.broken", ("reason", error.Message));
}
catch (Exception)
{
// До „чак сега се пипа играта“ кодът само чете: каквато и грешка да падне, тя идва от данните.
return T("load.broken", ("reason", T("load.badData")));
}
}

Какво още има в играта около този код:

  • Слотове. Менюто пише в слот 1, 2 или 3 чрез io.save(слот, текст) и чете чрез io.load(слот). В Node това са файлове в папка saves, в браузъра запис в localStorage. Самият запис не знае къде отива.
  • Развален слот за проверка. С --memory-saves записите са в паметта и main слага в слот 3 записа от TamperedSave(), на езика на играта: запис с hero.gold=99999 и сума, която не е на този текст. Така в записаното минаване на играта се вижда отказът: менюто пише „Записът е развален“ и играта продължава.
  • Резюме на слота. slotSummary чете само реда summary=, без да зарежда света: така менюто показва „Iva, варварин, ур.4, Сивия брод“ за всеки слот.
  • След зареждане. loadGame изчиства съобщенията и смята видимото наново с computeFov, а enterWorld казва „Заредено от слот 1“ и подменя стека с екрана на града или на картата (виж урока за стека на екраните).

Пробвай

Намери грешката

Колега реши, че е по-просто да слага стойностите направо в играта, докато ги чете. Подава се запис с вярна сума, но с hero.level=three:

var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
iva.Hero.Inventory.Add(new Slot("potion_heal", 2));
iva.Hero.Inventory.Add(new Slot("leather", 1));
var game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
string text = Resign(SaveGame(iva).Replace("hero.level=3", "hero.level=three"));
Console.WriteLine(LoadGame(game, text));
Console.WriteLine(HeroLine(game.Hero));
Изход
Записът е развален: грешна стойност на hero.level. Играта продължава без него.
Iva: ниво 1, живот 10, злато 7

Зареждането отказва, както трябва, но играта не е същата: името е Ива, а нивото, животът и златото са на Борис. Кой ред е първият, който пипа играта, преди записът да е прочетен докрай?

Избери реда с грешката.

По-точно

По-точно: сума, версии и какво още може да се обърка

Сумата не е подпис. Нашата е смесване на знаци с модул; има готови и по-силни: CRC32 хваща повреда от диск и мрежа по-надеждно, а SHA-256 е почти невъзможно да се подмени без да се усети. Но дори те не пазят от човек, който знае алгоритъма и пише нова вярна сума: resign в програмата прави точно това. Само подпис с таен ключ (HMAC) би го хванал, а в игра за един играч на неговия компютър няма таен ключ и няма смисъл от такава защита. Сумата тук е за повреда и непохватно пипане. Който иска да мами в игра за един, ще го направи, и това е негова работа.

Версии и миграции. Всяка промяна във формата е нова версия. Три пътя: да отказваш (нашето зареждане: „версия 2, а играта чете версия 1“), да четеш и старите версии с вграден превод (упражнението по-долу) или да ги четеш всички завинаги. Последното расте: след години в зареждането има код за дузина версии. Затова често се прави верига „миграции“: версия 1 → 2 → 3, всяка стъпка малка и с тест.

Запис по средата. Токът спира, докато файлът се пише: на диска остава половин файл. Контролната сума го хваща (последният ред липсва, „липсва checksum“), но и записът на играча е изгубен. По-добре е да пишеш във временен файл и да го преименуваш върху стария едва когато е записан докрай; преименуването е цяла операция. Нашият io.save пише направо, защото урокът не е за файлови системи.

Защо не JSON. JSON би свършил работа и ще го видиш в други уроци. Тук текстът ключ=стойност е нарочно прост: изходът трябва да е еднакъв на четири езика без библиотеки, а целият формат се чете на един екран. Цената е, че вложените неща (торба, враг с ефекти) се слепват на ръка с : и ,.

Двоичен формат. По-малък и по-бърз, но не се чете и не се сравнява с очи. За текстова игра с няколко килобайта записи е излишен.

Упражнение

Версия 2: мана

Реши задачата в редактора си. Тръгни от програмата за зареждането.

Мана и две версии. Героят получава мана (mana, по подразбиране 10). Версия 2 на формата добавя ред hero.mana веднага след hero.hp. Играта вече пише версия 2, но още чете и записи от версия 1: те нямат мана и героят я получава по подразбиране. Версия 3 (и нагоре) се отказва със същото съобщение като досега, а readValues приема версия между 1 и най-новата. Реши така, че loadGame да върне null и за двата вида записа.

Ето записа от версия 1 (какъвто го пише програмата от урока), помощната функция и тялото:

// Запис, какъвто го пише версия 1 на играта.
string V1_SAVE = string.Join("\n",
"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") + "\n";
void Check(Game game, int number, string text)
{
string error = LoadGame(game, text);
Console.WriteLine(number + ": " + (error == null ? "ok" : error));
Console.WriteLine(HeroLine(game.Hero));
}
var source = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);
source.Hero.Inventory.Add(new Slot("potion_heal", 2));
source.Hero.Inventory.Add(new Slot("leather", 1));
source.Hero.Mana = 8;
string v2 = SaveGame(source);
var 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"));

Изходът е:

Изход
1: ok
Iva: ниво 3, живот 27, злато 42, мана 10
2: ok
Iva: ниво 3, живот 27, злато 42, мана 8
3: Записът е развален: версия 3, а играта чете версия 2. Играта продължава без него.
Iva: ниво 3, живот 27, злато 42, мана 8

Още задачи

  • Автоматичен запис. Пиши в отделен слот auto на всеки 100 хода. Как менюто разбира, че слотът е автоматичен?
  • Откъснат файл. Махни последния ред от запис (без сумата). Какво казва зареждането? Промени io.save да пише във временен файл и да го преименува.
  • Резюме на слота. Добави ред summary= с името и нивото и функция, която го чете, без да зарежда света, като slotSummary в канона.
  • Два пъти един ключ. Сега последният hero.gold= печели тихо, защото таблицата замества. Накарай readValues да отказва записи с повтарящ се ключ.

Тест

Провери се

1. Записът е от версия 2 и сумата му не съвпада, но го зареждаш в игра, която чете версия 1. Коя грешка ще види играчът и защо?
2. Сумата на „ab“ е 3105, а на „ba“ е 3135. Защо тя не е просто сбор от кодовете на знаците (195 и за двата низа)?
3. Някой, който знае как се смята сумата, вдига златото на 99999 и записва в края сумата на новия текст. Какво става при зареждане?
4. Зареждането чете всичко в локални променливи и чак после ги слага в играта. Защо?
5. Канонът записва и състоянието на генератора на случайни числа (rng). Защо?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·