Запис и зареждане
Накратко: Записът е светът като текст „ключ=стойност“ с версия и контролна сума най-отдолу; зареждането чете и проверява всичко в локални променливи и пипа играта чак накрая, затова развален запис се отказва, без да я повреди.
Проблем
Играта, която забравя всичко
Играч е прекарал два часа в Сивия брод: взел е броня, убил е стража на мината, събрал е злато. Затваря терминала. На сутринта играта започва отначало и той не се връща. Запис и зареждане са това, което превръща играта от сесия в пътуване.
Задачата изглежда проста („запиши променливите във файл“), но стоят три въпроса:
- Какво се записва? Не кодът и не таблиците (предмети, врагове, умения: те са в играта). Записва се само това, което играта е променила: героят, ходът, картата, враговете, куестовете. Останалото се строи наново.
- Откъде зареждането знае, че файлът е добър? Файлът може да е на друга игра, от по-стара или по-нова версия, откъснат по средата или пипнат на ръка.
- Какво прави, когато не е добър? Не се срутва и не остава наполовина заредена.
Урокът строи малък свят (герой, торба, ход) и прекарва през него и трите въпроса: записът като текст, версия и контролна сума, зареждане, което първо чете и проверява, и шест развалени записа. После показва същото в истинската игра.
Идеята
Светът като текст: ключ=стойност
Записът е обикновен текст, ред по ред: ключ, знак за равно, стойност. Един ред е един факт. Така файлът се чете с очи, сравнява се с друг запис в редактор и не иска библиотека. Ето типовете на малкия свят: герой с торба, игра и грешка при записа.
record Slot(String id, int count) {}
class Hero { String name; int level; int hp; int gold; int x; int y; List<Slot> inventory = new ArrayList<>();
Hero(String name, int level, int hp, int gold, int x, int y) { this.name = name; this.level = level; this.hp = hp; this.gold = gold; this.x = x; this.y = y; }}
class Game { Hero hero; int turn;
Game(Hero hero, int turn) { this.hero = hero; this.turn = turn; }}
class SaveError extends RuntimeException { SaveError(String message) { super(message); }}А ето записът. Най-отгоре са форматът и версията, после светът, а най-отдолу е контролната сума (за нея след малко):
static final String SAVE_FORMAT = "ARCANUM";static final int SAVE_VERSION = 1;
// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.static int checksum(String text) { long sum = 0; for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007; return (int) sum;}
// Празният списък се пише като тире: така редът не остава с празна стойност.static String joinOrDash(List<String> parts) { return parts.isEmpty() ? "-" : String.join(",", parts);}
static String sign(String body) { return body + "checksum=" + checksum(body) + "\n";}
static String saveGame(Game game) { Hero hero = game.hero; List<String> lines = new ArrayList<>(); 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); List<String> bag = new ArrayList<>(); for (Slot slot : hero.inventory) bag.add(slot.id() + ":" + slot.count()); lines.add("hero.inventory=" + joinOrDash(bag)); return sign(String.join("\n", lines) + "\n");}Торбата е един ред: предмет:брой, разделени със запетая. Празната торба се пише като тире: ред hero.inventory= с празна стойност би изглеждал като липсваща. Всеки ред се прави с ключ=стойност, всичките се слепват с нов ред, и накрая sign слага сумата. Програмата записва Ива и после същата игра с празна торба (само двата реда, които се променят):
Game 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));System.out.print(saveGame(game));
game.hero.inventory.clear();String[] again = saveGame(game).split("\n");System.out.println(again[9]);System.out.println(again[10]);format=ARCANUMversion=1turn=212hero.name=Ivahero.level=3hero.hp=27hero.gold=42hero.x=5hero.y=2hero.inventory=potion_heal:2,leather:1checksum=624337035hero.inventory=-checksum=126903799Втората сума е съвсем друга, макар да е променен един ред: това е целта ѝ.
По-точно, за любопитните
Какво канонът записва отгоре на нашия свят: семето и състоянието на генератора (seed, rng, ids), така че след зареждане зарчетата да следват същата редица; нивата (три карти, ред по ред, с видяното и украсата); враговете с живот, състояние и ефекти; куестовете и флаговете. Това не са таблиците с предмети и врагове: те са в играта и записът пази само id (potion_heal:2), а не целия предмет.
Какво не се записва, защото се смята наново: показателите със слоевете им (база, екипировка, ефекти), полето на видимост (след зареждане играта вика computeFov) и всичко, което се строи от таблиците. По-малко в записа значи по-малко места за грешка.
Идеята
Версия и контролна сума
Два реда пазят записа от неприятни изненади.
Версията (version=1) казва кой вариант на формата е писал файла. Играта ще се промени: ще има мана, ще има нови редове. Старата игра, която прочете нов запис, не бива да гадае; и новата, която прочете стар, трябва или да знае как, или да откаже ясно. Затова версията е втори ред и се проверява преди всичко друго, освен заглавието.
Контролната сума (checksum=...) е едно число от всички редове над нея. Ето я цялата:
// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.static int checksum(String text) { long sum = 0; for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007; return (int) sum;}Върви по знаците на текста: сумата се умножава по 31 и се добавя кодът на знака, а остатъкът от 1000000007 я пази малка. Умножението е важно. Ако само събираше кодовете, „ab“ и „ba“ щяха да са едно число. Ето какво дава това:
// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.static int checksum(String text) { long sum = 0; for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007; return (int) sum;}
System.out.println(checksum("ab"));System.out.println(checksum("ba"));„ab“ е 97 · 31 + 98 = 3105, а „ba“ е 98 · 31 + 97 = 3135: при размяна на две букви сумата е друга. Вторият отговор би дал еднаква сума за различни низове, а третият е простият сбор от кодовете (97 + 98 = 195), в който редът не се вижда.
Записът е готов, когато сумата на всичко над последния ред е написана най-отдолу. Който промени един знак, ще види сумата да не съвпада.
Вchecksum сметката е в long: sum * 31 с int би препълнило преди остатъка. Резултатът е под 1000000007 и се връща като int.// Празният списък се пише като тире: така редът не остава с празна стойност.
static String joinOrDash(List<String> parts) {
return parts.isEmpty() ? "" : String.join(",", parts);
}Празният списък се пише като тире. Празен низ би дал ред hero.inventory= без стойност: при четене split на празния низ връща един празен елемент и зареждането би видяло предмет без име. Тирето казва ясно „няма нищо“, а зареждането го чете като нарочен знак.
Идеята
Зареждане: първо четеш, после пипаш
Зареждането е огледалото на записа и има едно правило: докато не е прочетено и проверено всичко, играта не се пипа. Всичко се чете в локални променливи, а присвояването към играта е последното нещо в метода.
Първо проверките. readValues взема текста и връща таблица ключ → стойност, а при всяко нередно нещо хвърля SaveError със съобщението за играча:
// Само цифри, най-много девет.static boolean isNumber(String text) { if (text.length() == 0 || text.length() > 9) return false; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c < '0' || c > '9') return false; } return true;}
static String need(Map<String, String> values, String key) { if (!values.containsKey(key)) throw new SaveError("липсва " + key); return values.get(key);}
static int needNumber(Map<String, String> values, String key) { String value = need(values, key); if (!isNumber(value)) throw new SaveError("грешна стойност на " + key); return Integer.parseInt(value);}
static Map<String, String> readValues(String text) { String[] lines = text.split("\n"); if (lines.length == 0 || !lines[0].equals("format=" + SAVE_FORMAT)) throw new SaveError("това не е запис на Арканум"); Map<String, String> values = new HashMap<>(); for (int i = 0; i < lines.length; i++) { int eq = lines[i].indexOf('='); if (eq <= 0) throw new SaveError("ред " + (i + 1) + " не е във вида ключ=стойност"); values.put(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } String version = need(values, "version"); if (!version.equals(String.valueOf(SAVE_VERSION))) throw new SaveError("версия " + version + ", а играта чете версия " + SAVE_VERSION); String last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError("липсва checksum"); String body = String.join("\n", Arrays.copyOf(lines, lines.length - 1)) + "\n"; int actual = checksum(body); String stored = last.substring(9); if (!stored.equals(String.valueOf(actual))) throw new SaveError("контролната сума не съвпада (записана " + stored + ", изчислена " + actual + "), някой е пипал файла"); return values;}Редът е важен: заглавие (format=ARCANUM), всеки ред да е ключ=стойност, версията, сумата. Сумата се смята от всичко над последния ред и се сравнява със записаната там; need и needNumber са стражите на ключовете (липсва ли, и числото число ли е).
После героят и самото зареждане:
static Hero readHero(Map<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"); Hero hero = new Hero(name, level, hp, gold, x, y); String bag = need(values, "hero.inventory"); if (!bag.equals("-")) { for (String part : 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], Integer.parseInt(pieces[1]))); } } return hero;}
// Връща null, ако всичко е минало, иначе текста на проблема.static String loadGame(Game game, String text) { try { Map<String, String> 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.getMessage() + ". Играта продължава без него."; }}readHero събира героя от таблицата; loadGame е кратка обвивка: чете героя и хода и чак тогава ги присвоява. SaveError се хваща и става текст; това, което тръгва, е играта без промяна. Резултатът е null при успех или съобщение за играча. Ето запис и зареждане в друга игра:
Game 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);
Game copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);String error = loadGame(copy, text);System.out.println(error == null ? "ok" : error);System.out.println(heroLine(copy.hero));System.out.println(saveGame(copy).equals(text) ? "Записът, написан наново, е същият." : "Записът, написан наново, е различен.");okIva: ниво 3, живот 27, злато 42Записът, написан наново, е същият.Записът на заредената игра е същият текст като първия: затова записът и зареждането са огледални. Борис е изчезнал, Ива е в играта.
Integer.parseInt приема и „+7“, затова isNumber проверява сам, че са само цифри (най-много девет, да се побират в int). text.split("\n") изхвърля празните редове от края, затова няма празен последен елемент.По-точно, за любопитните
Каноничният loadGame прави още нещо: ако при четене падне грешка, която не е SaveError (например ръчно развален запис с вярна сума и число с неочакван вид в ред, който се чете с toInt), я превръща в „полетата не са във вида, който играта пише“. Правилото „докато чета, не пипам играта“ прави това безопасно: каквато и грешка да падне, играта е същата. Именно такъв запис срутваше първата версия на канона.
Връщането на null или текст е стил на канона: екранът пише резултата в менюто. В друг код същото би било изключение, което стига до екрана.
Виж го
Запис, сума, отказ
Ето записа и зареждането по ходове. Ива се пише ред по ред, сумата расте с всеки ред. Друга игра (Борис) зарежда записа: проверките минават през заглавието, версията и всичките редове и сумата се получава същата. После златото е вдигнато на 99999 и зареждането отказва.
// Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.static int checksum(String text) { long sum = 0; for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007; return (int) sum;}
static String sign(String body) { return body + "checksum=" + checksum(body) + "\n";}
static String saveGame(Game game) { Hero hero = game.hero; List<String> lines = new ArrayList<>(); 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); List<String> bag = new ArrayList<>(); for (Slot slot : hero.inventory) bag.add(slot.id() + ":" + slot.count()); lines.add("hero.inventory=" + joinOrDash(bag)); return sign(String.join("\n", lines) + "\n");}
static Map<String, String> readValues(String text) { String[] lines = text.split("\n"); if (lines.length == 0 || !lines[0].equals("format=" + SAVE_FORMAT)) throw new SaveError("това не е запис на Арканум"); Map<String, String> values = new HashMap<>(); for (int i = 0; i < lines.length; i++) { int eq = lines[i].indexOf('='); if (eq <= 0) throw new SaveError("ред " + (i + 1) + " не е във вида ключ=стойност"); values.put(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } String version = need(values, "version"); if (!version.equals(String.valueOf(SAVE_VERSION))) throw new SaveError("версия " + version + ", а играта чете версия " + SAVE_VERSION); String last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError("липсва checksum"); String body = String.join("\n", Arrays.copyOf(lines, lines.length - 1)) + "\n"; int actual = checksum(body); String stored = last.substring(9); if (!stored.equals(String.valueOf(actual))) throw new SaveError("контролната сума не съвпада (записана " + stored + ", изчислена " + actual + "), някой е пипал файла"); return values;}
// Връща null, ако всичко е минало, иначе текста на проблема.static String loadGame(Game game, String text) { try { Map<String, String> 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.getMessage() + ". Играта продължава без него."; }}Стъпка по стъпка, с думи
- Играта на Ива е в паметта. За записа я пишем като текст, ред по ред „ключ=стойност“. Най-отгоре са форматът и версията: така зареждането знае дали чете запис на тази игра и на тази версия.
- Следват редовете на света: ход, име, ниво, живот, злато, място и торба. Торбата е един ред (предмет:брой), празната е „-“. Всеки ред е едно нещо, което играта трябва да знае, за да продължи оттам.
- Последният ред е контролната сума. Тя расте с всеки ред (числото вдясно): сумата се умножава по 31 и се добавя кодът на всеки знак. Всичко над нея дава 624337035; промени един знак и числото е друго.
- Друга игра (Борис) зарежда записа. Зареждането още не пипа играта: първо чете. Проверява заглавието (формат), всеки ред да е „ключ=стойност“ и версията.
- Сумата се смята наново от същите редове (лентата минава през тях). Получава се 624337035, колкото е записано: файлът е такъв, какъвто е писан.
- Чак сега, когато всичко е прочетено и проверено, играта на Борис получава героя от записа. Ива е заредена: 42 злато, ниво 3.
- Някой отваря файла и вдига златото: hero.gold=42 става hero.gold=99999. Файлът още е четим текст, всеки ред е „ключ=стойност“. Но сумата в края е написана за старите редове.
- Зареждането пак смята сумата. До златото числата са същите, оттам са други (в оранжево) и накрая се получава 748631855. Записаната е 624337035: не съвпадат.
- Отказ: „Записът е развален: контролната сума не съвпада (записана 624337035, изчислена 748631855), някой е пипал файла. Играта продължава без него.“ Играта остава каквато е: Ива с 42 злато. 99999 не стига до нея.
Кодът до сцената е checksum, sign, saveGame, readValues и loadGame от програмата. На всяка стъпка светят редовете, които работят в този момент.
Идеята
Шест развалени записа
Чете се записът на Ива, но шест пъти преди това файлът е развален по различен начин. Програмата пуска всичките в играта на Борис и печата отказа за всеки. resign в нея е „някой, който знае как се смята сумата“:
// Маха старата сума и слага нова: това е „някой, който знае как се смята сумата“.static String resign(String text) { return sign(text.substring(0, text.lastIndexOf("checksum=")));}
static String heroLine(Hero hero) { return hero.name + ": ниво " + hero.level + ", живот " + hero.hp + ", злато " + hero.gold;}Game 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);
List<String> broken = new ArrayList<>();// Някой е вдигнал златото на ръка: сумата е стара.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"));
Game game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);for (int i = 0; i < broken.size(); i++) { System.out.println((i + 1) + ": " + loadGame(game, broken.get(i)));}System.out.println(heroLine(game.hero));// Целият запис влиза.System.out.println(loadGame(game, good) == null ? "ok" : "no");System.out.println(heroLine(game.hero));1: Записът е развален: контролната сума не съвпада (записана 624337035, изчислена 748631855), някой е пипал файла. Играта продължава без него.2: Записът е развален: това не е запис на Арканум. Играта продължава без него.3: Записът е развален: версия 2, а играта чете версия 1. Играта продължава без него.4: Записът е развален: липсва hero.gold. Играта продължава без него.5: Записът е развален: грешна стойност на hero.level. Играта продължава без него.6: Записът е развален: ред 6 не е във вида ключ=стойност. Играта продължава без него.Boris: ниво 1, живот 10, злато 7okIva: ниво 3, живот 27, злато 42Всеки отказ е на собствена проверка:
| № | Какво е пипнато | Коя проверка го хваща |
|---|---|---|
| 1 | златото вдигнато на ръка, сумата е стара | сума: записаната и изчислената се различават |
| 2 | файл на друга игра | заглавие: първият ред не е format=ARCANUM |
| 3 | запис от версия 2 | версия: минава преди сумата |
| 4 | липсва ред, сумата е наново и вярна | съдържание: need не намира hero.gold |
| 5 | hero.level=three, сумата е наново и вярна |
съдържание: needNumber не вижда число |
| 6 | ред без знак за равно | форма на реда: още при разчитането |
Четвъртият и петият минават покрай сумата, защото са подписани наново: затова зареждането има и втори слой. Сумата хваща повреда, а need и needNumber хващат файл, който е цял и грешен.
Накрая Борис е непокътнат (Boris: ниво 1 ...), докато не влезе здравият запис: тогава е Ива. Нито един от шестте не го е променил.
Идеята
В играта
Малкият свят е същият като в канона, само по-малък. Ето истинската контролна сума. Смятането е същото; разликата е само в типа на резултата:
static int checksum(String text) { long sum = 0; for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007; return (int) sum;}А ето записът на цялата игра. Освен нашите редове има seed, rng, ids и summary (редът за менюто със слотове: име, клас, ниво и място), а за всяко от трите нива редовете на картата, видяното, украсата, предметите, враговете с ефектите им и хората:
static String saveGame(Game game) { List<String> lines = new ArrayList<>(); java.util.function.BiConsumer<String, Object> put = (key, value) -> lines.add(key + "=" + value); Hero hero = game.hero; put.accept("format", SAVE_FORMAT); put.accept("version", SAVE_VERSION); put.accept("summary", t("slot.summary", "name", hero.name, "heroClass", nm(hero.heroClass()), "level", hero.level, "place", placeName(game))); // Семето и генераторът са 32-битови числа без знак, както в JavaScript. put.accept("seed", Integer.toUnsignedString(game.seed)); put.accept("rng", Integer.toUnsignedString(game.rng.state)); put.accept("ids", game.ids.next); put.accept("turn", game.turn); put.accept("depth", game.depth); put.accept("victory", game.victory ? 1 : 0); put.accept("hero.name", hero.name); put.accept("hero.class", hero.classId); put.accept("hero.level", hero.level); put.accept("hero.xp", hero.xp); put.accept("hero.gold", hero.gold); put.accept("hero.hp", hero.hp); put.accept("hero.mana", hero.mana); put.accept("hero.x", hero.x); put.accept("hero.y", hero.y); List<String> base = new ArrayList<>(); for (String stat : STAT_NAMES) base.add(String.valueOf(hero.base.get(stat))); put.accept("hero.base", String.join(",", base)); put.accept("hero.skills", joinOrDash(hero.skills)); put.accept("hero.cooldowns", cooldownsText(hero)); put.accept("hero.effects", effectsText(hero)); List<String> equipment = new ArrayList<>(); for (String slot : hero.equipment.keySet()) equipment.add(hero.equipment.get(slot).equals("") ? "-" : hero.equipment.get(slot)); put.accept("hero.equipment", String.join(",", equipment)); List<String> inventory = new ArrayList<>(); for (ItemStack slot : hero.inventory.slots) inventory.add(slot.id + ":" + slot.count); put.accept("hero.inventory", joinOrDash(inventory)); Stats stats = game.stats; put.accept("stats", stats.turns + "," + stats.kills + "," + stats.blood + "," + stats.dealt + "," + stats.taken + "," + stats.potions); put.accept("flags", joinOrDash(game.flags)); for (Quest quest : game.quests) put.accept("quest." + quest.id, quest.state + ":" + quest.stage); for (Level level : 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.accept(prefix + "tiles." + y, new String(level.tiles[y])); put.accept(prefix + "seen." + y, seen); put.accept(prefix + "decor." + y, decor); } List<String> items = new ArrayList<>(); for (FloorItem item : level.items) items.add(item.x + ":" + item.y + ":" + item.id + ":" + item.count); put.accept(prefix + "items", joinOrDash(items)); put.accept(prefix + "enemies", level.enemies.size()); for (int i = 0; i < level.enemies.size(); i++) { Enemy enemy = level.enemies.get(i); put.accept(prefix + "enemy." + i, enemy.kind.id + "," + enemy.x + "," + enemy.y + "," + enemy.hp + "," + enemy.state + "," + enemy.calm + "," + enemy.lostTurns + "," + enemy.phase); put.accept(prefix + "enemy." + i + ".effects", effectsText(enemy)); put.accept(prefix + "enemy." + i + ".cooldowns", cooldownsText(enemy)); } List<String> npcs = new ArrayList<>(); for (Npc npc : level.npcs) npcs.add(npc.id + ":" + npc.x + ":" + npc.y); put.accept(prefix + "npcs", joinOrDash(npcs)); } String body = String.join("\n", lines) + "\n"; return body + "checksum=" + checksum(body) + "\n";}Четенето в канона е клас SaveReader: конструкторът прави същите проверки като нашия readValues, а text, int, ints и list са стражите. Ето го:
static class SaveReader { Map<String, String> values;
SaveReader(String text) { this.values = new LinkedHashMap<>(); List<String> lines = split(text, "\n"); while (lines.size() > 0 && lines.get(lines.size() - 1).length() == 0) lines.remove(lines.size() - 1); if (lines.size() == 0 || !lines.get(0).equals("format=" + SAVE_FORMAT)) throw new SaveError(t("load.badHeader")); for (int i = 0; i < lines.size(); i++) { int eq = lines.get(i).indexOf('='); if (eq <= 0) throw new SaveError(t("load.badLine", "line", i + 1)); this.values.put(lines.get(i).substring(0, eq), lines.get(i).substring(eq + 1)); } String version = this.text("version"); if (!version.equals(String.valueOf(SAVE_VERSION))) throw new SaveError(t("load.badVersion", "found", version, "expected", SAVE_VERSION)); String last = lines.get(lines.size() - 1); if (!last.startsWith("checksum=")) throw new SaveError(t("load.missing", "key", "checksum")); String body = String.join("\n", lines.subList(0, lines.size() - 1)) + "\n"; int actual = checksum(body); Integer stored = parseIntStrict(last.substring(9)); if (stored == null || stored != actual) { throw new SaveError(t("load.badChecksum", "expected", last.substring(9), "actual", actual)); } }
String text(String key) { if (!this.values.containsKey(key)) throw new SaveError(t("load.missing", "key", key)); return this.values.get(key); }
int integer(String key) { Integer value = parseIntStrict(this.text(key)); if (value == null) throw new SaveError(t("load.badValue", "key", key)); return value; }
List<Integer> ints(String key, int count) { List<String> parts = split(this.text(key), ","); if (parts.size() != count) throw new SaveError(t("load.badValue", "key", key)); List<Integer> result = new ArrayList<>(); for (String part : parts) { Integer value = parseIntStrict(part); if (value == null) throw new SaveError(t("load.badValue", "key", key)); result.add(value); } return result; }
List<String> list(String key) { return splitList(this.text(key)); }}И обвивката, която пази играта. Присвояването е след коментара „Всичко е прочетено“, а catch превръща всяка грешка в текст:
// Връща null при успех или текста на грешката.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 ArrayList<>(); for (MapDef def : MAP_DEFS) levels.add(readLevel(reader, def, ids)); List<Quest> quests = createQuests(); for (Quest quest : quests) { List<String> pieces = split(reader.text("quest." + quest.id), ":"); if (pieces.size() != 2) throw new SaveError(t("load.badValue", "key", "quest." + quest.id)); quest.state = pieces.get(0); quest.stage = toInt(pieces.get(1)); } List<Integer> stats = reader.ints("stats", 6); int depth = reader.integer("depth"); if (depth < 0 || depth > levels.size()) throw new SaveError(t("load.badValue", "key", "depth")); // Всичко е прочетено: чак сега се пипа играта. game.hero = hero; game.levels = levels; game.useQuests(quests); game.flags = reader.list("flags"); game.stats = newStats(); game.stats.turns = stats.get(0); game.stats.kills = stats.get(1); game.stats.blood = stats.get(2); game.stats.dealt = stats.get(3); game.stats.taken = stats.get(4); game.stats.potions = stats.get(5); game.depth = depth; game.turn = reader.integer("turn"); game.victory = reader.integer("victory") == 1; game.rng.state = reader.integer("rng"); game.ids = new IdSource(Math.max(reader.integer("ids"), ids.next)); game.messages = new ArrayList<>(); if (depth > 0) computeFov(game.level(), hero.x, hero.y); return null; } catch (SaveError error) { return t("load.broken", "reason", error.getMessage()); } catch (RuntimeException error) { // До „чак сега се пипа играта“ кодът само чете: каквато и грешка да падне, тя идва от данните. 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:
Game 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));Game game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
String text = resign(saveGame(iva).replace("hero.level=3", "hero.level=three"));System.out.println(loadGame(game, text));System.out.println(heroLine(game.hero));Записът е развален: грешна стойност на hero.level. Играта продължава без него.Iva: ниво 1, живот 10, злато 7Зареждането отказва, както трябва, но играта не е същата: името е Ива, а нивото, животът и златото са на Борис. Кой ред е първият, който пипа играта, преди записът да е прочетен докрай?
Избери реда с грешката.
Първото присвояване към game (името) става веднага след readValues, преди нивото да е прочетено. Когато needNumber на нивото падне, името вече е сменено и играта е наполовина заредена. Правилно е да се чете всичко в локални променливи (както прави readHero) и да се присвоява накрая, когато нищо вече не може да падне.
По-точно
По-точно: сума, версии и какво още може да се обърка
Сумата не е подпис. Нашата е смесване на знаци с модул; има готови и по-силни: 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 на играта.static final 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";
static void check(Game game, int number, String text) { String error = loadGame(game, text); System.out.println(number + ": " + (error == null ? "ok" : error)); System.out.println(heroLine(game.hero));}
Game 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);
Game 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: okIva: ниво 3, живот 27, злато 42, мана 102: okIva: ниво 3, живот 27, злато 42, мана 83: Записът е развален: версия 3, а играта чете версия 2. Играта продължава без него.Iva: ниво 3, живот 27, злато 42, мана 8Още задачи
- Автоматичен запис. Пиши в отделен слот
autoна всеки 100 хода. Как менюто разбира, че слотът е автоматичен? - Откъснат файл. Махни последния ред от запис (без сумата). Какво казва зареждането? Промени
io.saveда пише във временен файл и да го преименува. - Резюме на слота. Добави ред
summary=с името и нивото и функция, която го чете, без да зарежда света, катоslotSummaryв канона. - Два пъти един ключ. Сега последният
hero.gold=печели тихо, защото таблицата замества. НакарайreadValuesда отказва записи с повтарящ се ключ.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Хвърляне и свои изключения · Списъци · HashMap и HashSet · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум