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

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

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

Проблем

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

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

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

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

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

Идеята

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

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

@dataclass
class Slot:
id: str
count: int
class Hero:
def __init__(self, name, level, hp, gold, x, y):
self.name = name
self.level = level
self.hp = hp
self.gold = gold
self.x = x
self.y = y
self.inventory = []
class Game:
def __init__(self, hero, turn):
self.hero = hero
self.turn = turn
class SaveError(Exception):
pass

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

SAVE_FORMAT = "ARCANUM"
SAVE_VERSION = 1
# Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total
# Празният списък се пише като тире: така редът не остава с празна стойност.
def join_or_dash(parts):
return "-" if len(parts) == 0 else ",".join(parts)
def sign(body):
return body + "checksum=" + str(checksum(body)) + "\n"
def save_game(game):
hero = game.hero
lines = []
lines.append(f"format={SAVE_FORMAT}")
lines.append(f"version={SAVE_VERSION}")
lines.append(f"turn={game.turn}")
lines.append(f"hero.name={hero.name}")
lines.append(f"hero.level={hero.level}")
lines.append(f"hero.hp={hero.hp}")
lines.append(f"hero.gold={hero.gold}")
lines.append(f"hero.x={hero.x}")
lines.append(f"hero.y={hero.y}")
bag = []
for slot in hero.inventory:
bag.append(f"{slot.id}:{slot.count}")
lines.append("hero.inventory=" + join_or_dash(bag))
return sign("\n".join(lines) + "\n")

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

game = Game(Hero("Iva", 3, 27, 42, 5, 2), 212)
game.hero.inventory.append(Slot("potion_heal", 2))
game.hero.inventory.append(Slot("leather", 1))
print(save_game(game), end="")
game.hero.inventory.clear()
again = save_game(game).split("\n")
print(again[9])
print(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=...) е едно число от всички редове над нея. Ето я цялата:

# Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total

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

# Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total
print(checksum("ab"))
print(checksum("ba"))
Отговор

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

В Python числата не препълват, затова сметката е без long. ord(ch) е кодът на знака.
# Празният списък се пише като тире: така редът не остава с празна стойност.
def join_or_dash(parts):
    return "" if len(parts) == 0 else ",".join(parts)

Идеята

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

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

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

# Само цифри, най-много девет.
def is_number(text):
return 0 < len(text) <= 9 and all(c in "0123456789" for c in text)
def need(values, key):
if key not in values:
raise SaveError(f"липсва {key}")
return values[key]
def need_number(values, key):
value = need(values, key)
if not is_number(value):
raise SaveError(f"грешна стойност на {key}")
return int(value)
def read_values(text):
lines = text.split("\n")
if lines[-1] == "":
lines.pop()
if len(lines) == 0 or lines[0] != "format=" + SAVE_FORMAT:
raise SaveError("това не е запис на Арканум")
values = {}
for i in range(len(lines)):
eq = lines[i].find("=")
if eq <= 0:
raise SaveError(f"ред {i + 1} не е във вида ключ=стойност")
values[lines[i][:eq]] = lines[i][eq + 1:]
version = need(values, "version")
if version != str(SAVE_VERSION):
raise SaveError(f"версия {version}, а играта чете версия {SAVE_VERSION}")
last = lines[-1]
if not last.startswith("checksum="):
raise SaveError(f"липсва checksum")
body = "\n".join(lines[:-1]) + "\n"
actual = checksum(body)
stored = last[9:]
if stored != str(actual):
raise SaveError(f"контролната сума не съвпада (записана {stored}, изчислена {actual}), някой е пипал файла")
return values

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

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

def read_hero(values):
name = need(values, "hero.name")
level = need_number(values, "hero.level")
hp = need_number(values, "hero.hp")
gold = need_number(values, "hero.gold")
x = need_number(values, "hero.x")
y = need_number(values, "hero.y")
hero = Hero(name, level, hp, gold, x, y)
bag = need(values, "hero.inventory")
if bag != "-":
for part in bag.split(","):
pieces = part.split(":")
if len(pieces) != 2 or not is_number(pieces[1]):
raise SaveError(f"грешна стойност на hero.inventory")
hero.inventory.append(Slot(pieces[0], int(pieces[1])))
return hero
# Връща null, ако всичко е минало, иначе текста на проблема.
def load_game(game, text):
try:
values = read_values(text)
hero = read_hero(values)
turn = need_number(values, "turn")
# Всичко е прочетено и проверено: чак сега се пипа играта.
game.hero = hero
game.turn = turn
return None
except SaveError as error:
return f"Записът е развален: {str(error)}. Играта продължава без него."

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

iva = Game(Hero("Iva", 3, 27, 42, 5, 2), 212)
iva.hero.inventory.append(Slot("potion_heal", 2))
iva.hero.inventory.append(Slot("leather", 1))
text = save_game(iva)
copy = Game(Hero("Boris", 1, 10, 7, 0, 0), 0)
error = load_game(copy, text)
print("ok" if error is None else error)
print(hero_line(copy.hero))
print("Записът, написан наново, е същият." if save_game(copy) == text else "Записът, написан наново, е различен.")
Изход
ok
Iva: ниво 3, живот 27, злато 42
Записът, написан наново, е същият.

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

int() приема и „ 7“, и „1_000“, затова is_number проверява сам, че са само цифри (най-много девет). str.isdigit() не става: приема и „²“, а int("²") пада. split оставя празен последен елемент; lines.pop() го маха.
По-точно, за любопитните

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

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

Виж го

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

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

# Сумата е от символите на всичко над нея; ако някой промени и една буква, тя не съвпада.
def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total
def sign(body):
return body + "checksum=" + str(checksum(body)) + "\n"
def save_game(game):
hero = game.hero
lines = []
lines.append(f"format={SAVE_FORMAT}")
lines.append(f"version={SAVE_VERSION}")
lines.append(f"turn={game.turn}")
lines.append(f"hero.name={hero.name}")
lines.append(f"hero.level={hero.level}")
lines.append(f"hero.hp={hero.hp}")
lines.append(f"hero.gold={hero.gold}")
lines.append(f"hero.x={hero.x}")
lines.append(f"hero.y={hero.y}")
bag = []
for slot in hero.inventory:
bag.append(f"{slot.id}:{slot.count}")
lines.append("hero.inventory=" + join_or_dash(bag))
return sign("\n".join(lines) + "\n")
def read_values(text):
lines = text.split("\n")
if lines[-1] == "":
lines.pop()
if len(lines) == 0 or lines[0] != "format=" + SAVE_FORMAT:
raise SaveError("това не е запис на Арканум")
values = {}
for i in range(len(lines)):
eq = lines[i].find("=")
if eq <= 0:
raise SaveError(f"ред {i + 1} не е във вида ключ=стойност")
values[lines[i][:eq]] = lines[i][eq + 1:]
version = need(values, "version")
if version != str(SAVE_VERSION):
raise SaveError(f"версия {version}, а играта чете версия {SAVE_VERSION}")
last = lines[-1]
if not last.startswith("checksum="):
raise SaveError(f"липсва checksum")
body = "\n".join(lines[:-1]) + "\n"
actual = checksum(body)
stored = last[9:]
if stored != str(actual):
raise SaveError(f"контролната сума не съвпада (записана {stored}, изчислена {actual}), някой е пипал файла")
return values
# Връща null, ако всичко е минало, иначе текста на проблема.
def load_game(game, text):
try:
values = read_values(text)
hero = read_hero(values)
turn = need_number(values, "turn")
# Всичко е прочетено и проверено: чак сега се пипа играта.
game.hero = hero
game.turn = turn
return None
except SaveError as error:
return f"Записът е развален: {str(error)}. Играта продължава без него."
Стъпка по стъпка, с думи
  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 в нея е „някой, който знае как се смята сумата“:

# Маха старата сума и слага нова: това е „някой, който знае как се смята сумата“.
def resign(text):
return sign(text[:text.rfind("checksum=")])
def hero_line(hero):
return f"{hero.name}: ниво {hero.level}, живот {hero.hp}, злато {hero.gold}"
iva = Game(Hero("Iva", 3, 27, 42, 5, 2), 212)
iva.hero.inventory.append(Slot("potion_heal", 2))
iva.hero.inventory.append(Slot("leather", 1))
good = save_game(iva)
broken = []
# Някой е вдигнал златото на ръка: сумата е стара.
broken.append(good.replace("hero.gold=42", "hero.gold=99999"))
# Файл на друга игра.
broken.append(good.replace("format=ARCANUM", "format=SOLITAIRE"))
# Записан от по-нова версия на играта.
broken.append(good.replace("version=1", "version=2"))
# Липсва ред, сумата е пресметната наново.
broken.append(resign(good.replace("hero.gold=42\n", "")))
# Лошо число, сумата е пресметната наново.
broken.append(resign(good.replace("hero.level=3", "hero.level=three")))
# Ред без знак за равно.
broken.append(good.replace("hero.hp=27", "hero.hp 27"))
game = Game(Hero("Boris", 1, 10, 7, 0, 0), 0)
for i in range(len(broken)):
print(f"{i + 1}: {load_game(game, broken[i])}")
print(hero_line(game.hero))
# Целият запис влиза.
print("ok" if load_game(game, good) is None else "no")
print(hero_line(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 ...), докато не влезе здравият запис: тогава е Ива. Нито един от шестте не го е променил.

Идеята

В играта

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

def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total

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

def save_game(game):
lines = []
def put(key, value):
lines.append(key + '=' + str(value))
hero = game.hero
put('format', SAVE_FORMAT)
put('version', SAVE_VERSION)
put('summary', t('slot.summary', {'name': hero.name, 'heroClass': nm(hero.hero_class()), 'level': hero.level, 'place': place_name(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', 1 if game.victory else 0)
put('hero.name', hero.name)
put('hero.class', hero.class_id)
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)
base = []
for stat in STAT_NAMES:
base.append(str(hero.base[stat]))
put('hero.base', ','.join(base))
put('hero.skills', join_or_dash(hero.skills))
put('hero.cooldowns', cooldowns_text(hero))
put('hero.effects', effects_text(hero))
equipment = []
for slot in hero.equipment.keys():
equipment.append('-' if hero.equipment[slot] == '' else hero.equipment[slot])
put('hero.equipment', ','.join(equipment))
inventory = []
for slot in hero.inventory.slots:
inventory.append(slot.id + ':' + str(slot.count))
put('hero.inventory', join_or_dash(inventory))
stats = game.stats
put('stats', ','.join(str(value) for value in [stats.turns, stats.kills, stats.blood, stats.dealt, stats.taken, stats.potions]))
put('flags', join_or_dash(game.flags))
for quest in game.quests:
put('quest.' + quest.id, quest.state + ':' + str(quest.stage))
for level in game.levels:
prefix = 'level.' + str(level.depth) + '.'
for y in range(level.height):
seen = ''
decor = ''
for x in range(level.width):
seen += '1' if level.seen[y][x] else '0'
decor += '_' if level.decor[y][x] == ' ' else level.decor[y][x]
put(prefix + 'tiles.' + str(y), ''.join(level.tiles[y]))
put(prefix + 'seen.' + str(y), seen)
put(prefix + 'decor.' + str(y), decor)
items = []
for item in level.items:
items.append(str(item.x) + ':' + str(item.y) + ':' + item.id + ':' + str(item.count))
put(prefix + 'items', join_or_dash(items))
put(prefix + 'enemies', len(level.enemies))
for i in range(len(level.enemies)):
enemy = level.enemies[i]
put(prefix + 'enemy.' + str(i), ','.join(str(value) for value in [enemy.kind.id, enemy.x, enemy.y, enemy.hp, enemy.state, enemy.calm, enemy.lost_turns, enemy.phase]))
put(prefix + 'enemy.' + str(i) + '.effects', effects_text(enemy))
put(prefix + 'enemy.' + str(i) + '.cooldowns', cooldowns_text(enemy))
npcs = []
for npc in level.npcs:
npcs.append(npc.id + ':' + str(npc.x) + ':' + str(npc.y))
put(prefix + 'npcs', join_or_dash(npcs))
body = '\n'.join(lines) + '\n'
return body + 'checksum=' + str(checksum(body)) + '\n'

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

class SaveReader:
def __init__(self, text):
self.values = {}
lines = text.split('\n')
while len(lines) > 0 and len(lines[-1]) == 0:
lines.pop()
if len(lines) == 0 or lines[0] != 'format=' + SAVE_FORMAT:
raise SaveError(t('load.badHeader'))
for i in range(len(lines)):
eq = lines[i].find('=')
if eq <= 0:
raise SaveError(t('load.badLine', {'line': i + 1}))
self.values[lines[i][:eq]] = lines[i][eq + 1:]
version = self.text('version')
if version != str(SAVE_VERSION):
raise SaveError(t('load.badVersion', {'found': version, 'expected': SAVE_VERSION}))
last = lines[-1]
if not last.startswith('checksum='):
raise SaveError(t('load.missing', {'key': 'checksum'}))
body = '\n'.join(lines[:-1]) + '\n'
actual = checksum(body)
if parse_int_strict(last[9:]) != actual:
raise SaveError(t('load.badChecksum', {'expected': last[9:], 'actual': actual}))
def text(self, key):
if key not in self.values:
raise SaveError(t('load.missing', {'key': key}))
return self.values[key]
def int(self, key):
value = parse_int_strict(self.text(key))
if value is None:
raise SaveError(t('load.badValue', {'key': key}))
return value
def ints(self, key, count):
parts = self.text(key).split(',')
if len(parts) != count:
raise SaveError(t('load.badValue', {'key': key}))
result = []
for part in parts:
value = parse_int_strict(part)
if value is None:
raise SaveError(t('load.badValue', {'key': key}))
result.append(value)
return result
def list(self, key):
return split_list(self.text(key))

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

# Връща None при успех или текста на грешката.
def load_game(game, text):
if text is None:
return t('load.empty')
try:
reader = SaveReader(text)
ids = IdSource(1)
hero = read_hero(reader, ids)
levels = []
for definition in MAP_DEFS:
levels.append(read_level(reader, definition, ids))
quests = create_quests()
for quest in quests:
pieces = reader.text('quest.' + quest.id).split(':')
if len(pieces) != 2:
raise SaveError(t('load.badValue', {'key': 'quest.' + quest.id}))
quest.state = pieces[0]
quest.stage = to_int(pieces[1])
stats = reader.ints('stats', 6)
depth = reader.int('depth')
if depth < 0 or depth > len(levels):
raise SaveError(t('load.badValue', {'key': 'depth'}))
# Всичко е прочетено: чак сега се пипа играта.
game.hero = hero
game.levels = levels
game.use_quests(quests)
game.flags = reader.list('flags')
game.stats = SimpleNamespace(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') & MASK32
game.ids = IdSource(max(reader.int('ids'), ids.next))
game.messages = []
if depth > 0:
compute_fov(game.level(), hero.x, hero.y)
return None
except SaveError as error:
return t('load.broken', {'reason': error.message})
except Exception:
# До „чак сега се пипа играта“ кодът само чете: каквато и грешка да падне, тя идва от данните.
return t('load.broken', {'reason': t('load.badData')})

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

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

Пробвай

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

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

iva = Game(Hero("Iva", 3, 27, 42, 5, 2), 212)
iva.hero.inventory.append(Slot("potion_heal", 2))
iva.hero.inventory.append(Slot("leather", 1))
game = Game(Hero("Boris", 1, 10, 7, 0, 0), 0)
text = resign(save_game(iva).replace("hero.level=3", "hero.level=three"))
print(load_game(game, text))
print(hero_line(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 на играта.
V1_SAVE = "\n".join([
"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"
def check(game, number, text):
error = load_game(game, text)
print(f"{number}: {'ok' if error is None else error}")
print(hero_line(game.hero))
source = Game(Hero("Iva", 3, 27, 42, 5, 2), 212)
source.hero.inventory.append(Slot("potion_heal", 2))
source.hero.inventory.append(Slot("leather", 1))
source.hero.mana = 8
v2 = save_game(source)
game = Game(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 за достъпно обучение по програмиране ·