Ir al contenido
wannacode

Guardar y cargar

En resumen: Una partida guardada es el mundo como texto «clave=valor» con una versión arriba y una suma de control abajo; la carga lee y comprueba todo en variables locales y toca el juego solo al final, así que una partida dañada se rechaza sin estropearlo.

Problema

El juego que lo olvida todo

Un jugador ha pasado dos horas en Vadogris: ha cogido una armadura, ha matado al guardián de la mina, ha reunido oro. Cierra la terminal. A la mañana siguiente el juego empieza de cero y no vuelve. Guardar y cargar es lo que convierte un juego de una sesión en un viaje.

La tarea parece simple («escribe las variables en un archivo»), pero detrás hay tres preguntas:

  1. ¿Qué se guarda? No el código ni las tablas (objetos, enemigos, habilidades: están en el juego). Solo se guarda lo que el juego ha cambiado: el héroe, el turno, el mapa, los enemigos, las misiones. Lo demás se construye de nuevo.
  2. ¿Cómo sabe la carga que el archivo es bueno? El archivo puede ser de otro juego, de una versión más vieja o más nueva, estar cortado a medias o tocado a mano.
  3. ¿Qué hace cuando no lo es? No se rompe y no se queda a medio cargar.

La lección construye un mundo pequeño (un héroe, una bolsa, un turno) y pasa por él las tres preguntas: la partida como texto, una versión y una suma de control, una carga que primero lee y comprueba, y seis partidas dañadas. Después muestra lo mismo en el juego real.

La idea

El mundo como texto: clave=valor

Una partida es texto simple, línea por línea: una clave, un signo igual, un valor. Una línea es un dato. Así el archivo se lee a simple vista, se compara con otra partida en un editor y no necesita ninguna biblioteca. Estos son los tipos del mundo pequeño: un héroe con bolsa, un juego y un error para las partidas.

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);
}
}

Y esta es la partida guardada. Arriba van el formato y la versión, luego el mundo, y abajo del todo la suma de control (de ella enseguida):

static final String SAVE_FORMAT = "ARCANUM";
static final int SAVE_VERSION = 1;
// La suma sale de los caracteres de todo lo que hay encima; si alguien cambia una sola letra, ya no coincide.
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;
}
// Una lista vacía se escribe como un guion, para que la línea nunca tenga un valor vacío.
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");
}

La bolsa es una línea: objeto:cantidad, separados por comas. Una bolsa vacía se escribe como un guion: una línea hero.inventory= con el valor vacío parecería una que falta. Cada línea se hace con clave=valor, todas se unen con saltos de línea, y al final sign pone la suma. El programa guarda a Iva y después el mismo juego con la bolsa vacía (solo las dos líneas que cambian):

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]);
Salida
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

La segunda suma es totalmente distinta aunque haya cambiado una línea: ese es su propósito.

Más precisamente, para curiosos

Lo que el canon guarda además de nuestro mundo: la semilla y el estado del generador (seed, rng, ids), para que tras cargar los dados sigan la misma secuencia; los niveles (tres mapas, fila por fila, con lo visto y la decoración); los enemigos con vida, estado y efectos; las misiones y las banderas. No son las tablas de objetos y enemigos: están en el juego, y una partida solo guarda el id (potion_heal:2), no el objeto entero.

Lo que no se guarda, porque se calcula de nuevo: los atributos con sus capas (base, equipo, efectos), el campo de visión (tras cargar el juego llama a computeFov) y todo lo que se construye a partir de las tablas. Menos en la partida significa menos sitios para errores.

La idea

Versión y suma de control

Dos líneas protegen la partida de sorpresas desagradables.

La versión (version=1) dice qué variante del formato escribió el archivo. El juego cambiará: habrá maná, habrá líneas nuevas. Un juego viejo que lea una partida nueva no debe adivinar; y el juego nuevo que lea una vieja debe saber cómo hacerlo o rechazarla con claridad. Por eso la versión es la segunda línea y se comprueba antes que todo lo demás, salvo la cabecera.

La suma de control (checksum=...) es un número hecho con todas las líneas que tiene encima. Aquí está entera:

// La suma sale de los caracteres de todo lo que hay encima; si alguien cambia una sola letra, ya no coincide.
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;
}

Recorre los caracteres del texto: la suma se multiplica por 31 y se añade el código del carácter, y el resto de 1000000007 la mantiene pequeña. La multiplicación importa. Si solo sumara los códigos, «ab» y «ba» serían el mismo número. Esto es lo que da:

// La suma sale de los caracteres de todo lo que hay encima; si alguien cambia una sola letra, ya no coincide.
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"));
Respuesta

La partida está lista cuando la suma de todo lo que hay sobre la última línea está escrita abajo del todo. Quien cambie un solo carácter verá que la suma no coincide.

En checksum la cuenta se hace en long: sum * 31 con un int desbordaría antes del resto. El resultado es menor que 1000000007 y se devuelve como int.
// Una lista vacía se escribe como un guion, para que la línea nunca tenga un valor vacío.
static String joinOrDash(List<String> parts) {
    return parts.isEmpty() ? "" : String.join(",", parts);
}

La idea

Cargar: primero lees, después tocas

Cargar es el espejo de guardar y tiene una regla: mientras no esté todo leído y comprobado, no se toca el juego. Todo se lee en variables locales, y la asignación al juego es lo último del método.

Primero las comprobaciones. readValues toma el texto y devuelve una tabla clave → valor, y ante cualquier cosa mal lanza un SaveError con el mensaje para el jugador:

// Solo cifras, como mucho nueve.
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("falta " + 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("valor incorrecto en " + 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("esto no es una partida de Arcanum");
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("la línea " + (i + 1) + " no tiene la forma clave=valor");
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("versión " + version + ", pero el juego lee la versión " + SAVE_VERSION);
String last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError("falta 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("la suma de control no coincide (guardada " + stored + ", calculada " + actual + "), alguien ha tocado el archivo");
return values;
}

El orden importa: la cabecera (format=ARCANUM), que cada línea sea clave=valor, la versión, la suma. La suma se calcula con todo lo que hay sobre la última línea y se compara con la guardada allí; need y needNumber son los guardianes de las claves (si falta, y si el número es un número).

Después el héroe y la carga misma:

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("valor incorrecto en hero.inventory");
hero.inventory.add(new Slot(pieces[0], Integer.parseInt(pieces[1])));
}
}
return hero;
}
// Devuelve null si todo fue bien; si no, el texto del problema.
static String loadGame(Game game, String text) {
try {
Map<String, String> values = readValues(text);
Hero hero = readHero(values);
int turn = needNumber(values, "turn");
// Todo está leído y comprobado: solo ahora se toca el juego.
game.hero = hero;
game.turn = turn;
return null;
} catch (SaveError error) {
return "La partida está dañada: " + error.getMessage() + ". El juego sigue sin ella.";
}
}

readHero construye el héroe a partir de la tabla; loadGame es una envoltura corta: lee el héroe y el turno y solo entonces los asigna. SaveError se captura y se convierte en texto; lo que sale es el juego, sin cambios. El resultado es null si todo fue bien o un mensaje para el jugador. Aquí una partida guardada y cargada en otro juego:

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) ? "Volver a escribir la partida da el mismo texto." : "Volver a escribir la partida da un texto distinto.");
Salida
ok
Iva: nivel 3, vida 27, oro 42
Volver a escribir la partida da el mismo texto.

La partida del juego cargado es el mismo texto que la primera: por eso guardar y cargar son espejos. Boris ha desaparecido, Iva está en el juego.

Integer.parseInt acepta también «+7», así que isNumber comprueba por su cuenta que solo hay cifras (como mucho nueve, para que quepan en un int). text.split("\n") descarta las cadenas vacías del final, así que no hay un último elemento vacío.
Más precisamente, para curiosos

El loadGame del canon hace algo más: si al leer cae un error que no es un SaveError (por ejemplo, una partida rota a mano con la suma correcta y un número de forma inesperada en una línea que se lee con toInt), lo convierte en «los campos no tienen la forma que escribe el juego». La regla «mientras leo, no toco el juego» hace esto seguro: caiga el error que caiga, el juego es el mismo. Justo una partida así hundía la primera versión del canon.

Devolver null o un texto es el estilo del canon: la pantalla escribe el resultado en el menú. En otro código lo mismo sería una excepción que llega hasta la pantalla.

Míralo

Guardar, suma, rechazo

Aquí están guardar y cargar paso a paso. Iva se escribe línea por línea, y la suma crece con cada línea. Otro juego (Boris) carga la partida: las comprobaciones recorren la cabecera, la versión y todas las líneas, y la suma sale igual. Después el oro se sube a 99999 y la carga rechaza.

// La suma sale de los caracteres de todo lo que hay encima; si alguien cambia una sola letra, ya no coincide.
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("esto no es una partida de Arcanum");
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("la línea " + (i + 1) + " no tiene la forma clave=valor");
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("versión " + version + ", pero el juego lee la versión " + SAVE_VERSION);
String last = lines[lines.length - 1];
if (!last.startsWith("checksum=")) throw new SaveError("falta 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("la suma de control no coincide (guardada " + stored + ", calculada " + actual + "), alguien ha tocado el archivo");
return values;
}
// Devuelve null si todo fue bien; si no, el texto del problema.
static String loadGame(Game game, String text) {
try {
Map<String, String> values = readValues(text);
Hero hero = readHero(values);
int turn = needNumber(values, "turn");
// Todo está leído y comprobado: solo ahora se toca el juego.
game.hero = hero;
game.turn = turn;
return null;
} catch (SaveError error) {
return "La partida está dañada: " + error.getMessage() + ". El juego sigue sin ella.";
}
}
Paso a paso, en palabras
  1. La partida de Iva está en memoria. Para guardarla la escribimos como texto, línea por línea como clave=valor. Arriba van el formato y la versión, para que la carga sepa si lee una partida de este juego y de esta versión.
  2. Siguen las líneas del mundo: turno, nombre, nivel, vida, oro, posición y bolsa. La bolsa es una línea (objeto:cantidad, separados por comas); una vacía es un guion. Cada línea es un dato que el juego necesita para seguir desde ahí.
  3. La última línea es la suma de control. Crece con cada línea (el número de la derecha): la suma se multiplica por 31 y se añade el código de cada carácter. Todo lo de encima da 624337035; cambia un carácter y el número es otro.
  4. Otra partida (Boris) carga el archivo. La carga aún no toca el juego: primero lee. Comprueba la cabecera (formato), que cada línea sea clave=valor y la versión.
  5. La suma se calcula de nuevo con las mismas líneas (la barra pasa por ellas). Sale 624337035, igual que la guardada: el archivo es tal como se escribió.
  6. Solo ahora, con todo leído y comprobado, la partida de Boris recibe al héroe del archivo. Iva está cargada: 42 de oro, nivel 3.
  7. Alguien abre el archivo y sube el oro: hero.gold=42 pasa a ser hero.gold=99999. El archivo sigue siendo texto legible, cada línea es clave=valor. Pero la suma del final se escribió para las líneas viejas.
  8. La carga calcula la suma otra vez. Hasta el oro los números son iguales, desde ahí difieren (en naranja) y al final sale 748631855. La guardada es 624337035: no coinciden.
  9. Rechazo: «La partida está dañada: la suma de control no coincide (guardada 624337035, calculada 748631855), alguien ha tocado el archivo. El juego sigue sin ella.» La partida se queda como está: Iva con 42 de oro. El 99999 nunca llega a ella.

El código junto a la escena es checksum, sign, saveGame, readValues y loadGame del programa. En cada paso se iluminan las líneas que trabajan en ese momento.

La idea

Seis partidas dañadas

Lo que se lee es la partida de Iva, pero seis veces antes el archivo estuvo dañado de una manera distinta. El programa las pasa todas al juego de Boris e imprime el rechazo de cada una. resign en él es «alguien que sabe cómo se calcula la suma»:

// Quita la suma vieja y pone una nueva: es alguien que sabe cómo se calcula la suma.
static String resign(String text) {
return sign(text.substring(0, text.lastIndexOf("checksum=")));
}
static String heroLine(Hero hero) {
return hero.name + ": nivel " + hero.level + ", vida " + hero.hp + ", oro " + 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<>();
// Alguien subió el oro a mano: la suma es la vieja.
broken.add(good.replace("hero.gold=42", "hero.gold=99999"));
// El archivo de otro juego.
broken.add(good.replace("format=ARCANUM", "format=SOLITAIRE"));
// Escrita por una versión más nueva del juego.
broken.add(good.replace("version=1", "version=2"));
// Falta una línea, la suma se calcula de nuevo.
broken.add(resign(good.replace("hero.gold=42\n", "")));
// Un número malo, la suma se calcula de nuevo.
broken.add(resign(good.replace("hero.level=3", "hero.level=three")));
// Una línea sin signo igual.
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));
// La partida entera entra.
System.out.println(loadGame(game, good) == null ? "ok" : "no");
System.out.println(heroLine(game.hero));
Salida
1: La partida está dañada: la suma de control no coincide (guardada 624337035, calculada 748631855), alguien ha tocado el archivo. El juego sigue sin ella.
2: La partida está dañada: esto no es una partida de Arcanum. El juego sigue sin ella.
3: La partida está dañada: versión 2, pero el juego lee la versión 1. El juego sigue sin ella.
4: La partida está dañada: falta hero.gold. El juego sigue sin ella.
5: La partida está dañada: valor incorrecto en hero.level. El juego sigue sin ella.
6: La partida está dañada: la línea 6 no tiene la forma clave=valor. El juego sigue sin ella.
Boris: nivel 1, vida 10, oro 7
ok
Iva: nivel 3, vida 27, oro 42

Cada rechazo pertenece a una comprobación propia:

N.º Qué se tocó Qué comprobación lo detecta
1 oro subido a mano, suma vieja suma: la guardada y la calculada difieren
2 el archivo de otro juego cabecera: la primera línea no es format=ARCANUM
3 una partida de la versión 2 versión: va antes que la suma
4 falta una línea, suma nueva y correcta contenido: need no encuentra hero.gold
5 hero.level=three, suma nueva y correcta contenido: needNumber no ve un número
6 una línea sin signo igual forma de la línea: ya al separar

La cuarta y la quinta pasan la suma porque están firmadas de nuevo: por eso la carga tiene una segunda capa. La suma detecta daños, y need y needNumber detectan un archivo que está entero y mal.

Al final Boris está intacto (Boris: nivel 1 ...) hasta que entra la partida buena: entonces es Iva. Ninguna de las seis lo cambió.

La idea

En el juego

El mundo pequeño es el mismo que en el canon, solo que más pequeño. Esta es la suma de control real. El cálculo es el mismo; la única diferencia es el tipo del resultado:

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;
}

Y esta es la partida del juego entero. Además de nuestras líneas hay seed, rng, ids y summary (la línea para el menú de ranuras: nombre, clase, nivel y lugar), y para cada uno de los tres niveles las filas del mapa, lo visto, la decoración, los objetos, los enemigos con sus efectos y las personas:

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)));
// La semilla y el generador son números de 32 bits sin signo, como en 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";
}

La lectura en el canon es una clase SaveReader: el constructor hace las mismas comprobaciones que nuestro readValues, y text, int, ints y list son los guardianes. Aquí está:

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));
}
}

Y la envoltura que protege el juego. La asignación va después del comentario «Todo está leído», y catch convierte cualquier error en texto:

// Devuelve null si todo va bien, o el texto del error.
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"));
// Todo está leído: solo ahora se toca el juego.
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) {
// Hasta «solo ahora se toca el juego» el código solo lee: cualquier error que aparezca viene de los datos.
return t("load.broken", "reason", t("load.badData"));
}
}

Qué más tiene el juego alrededor de este código:

  • Ranuras. El menú escribe en la ranura 1, 2 o 3 con io.save(ranura, texto) y lee con io.load(ranura). En Node son archivos en una carpeta saves, en el navegador una entrada en localStorage. La partida en sí no sabe adónde va.
  • Una ranura dañada para probar. Con --memory-saves las partidas están en memoria y main pone en la ranura 3 la partida de tamperedSave(), en el idioma del juego: una partida con hero.gold=99999 y una suma que no es de este texto. Así la partida grabada del juego muestra el rechazo: el menú dice «La partida está dañada» y el juego sigue.
  • El resumen de la ranura. slotSummary lee solo la línea summary=, sin cargar el mundo: así el menú muestra «Iva, bárbaro, nv.4, Vadogris» para cada ranura.
  • Tras cargar. loadGame vacía los mensajes y calcula de nuevo lo visible con computeFov, y enterWorld dice «Cargado de la ranura 1» y sustituye la pila por la pantalla de la ciudad o del mapa (mira la lección sobre la pila de pantallas).

Pruébalo

Encuentra el fallo

Un compañero pensó que era más simple poner los valores directamente en el juego mientras los lee. Se da una partida con la suma correcta pero con 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));
Salida
La partida está dañada: valor incorrecto en hero.level. El juego sigue sin ella.
Iva: nivel 1, vida 10, oro 7

La carga rechaza, como debe, pero el juego no es el mismo: el nombre es Iva, mientras que el nivel, la vida y el oro son de Boris. ¿Qué línea es la primera que toca el juego antes de que la partida esté leída por completo?

Elige la línea con el error.

Más precisamente

Con más precisión: sumas, versiones y qué más puede salir mal

Una suma de control no es una firma. La nuestra mezcla caracteres con un módulo; hay otras ya hechas y más fuertes: CRC32 detecta mejor los daños de discos y redes, y con SHA-256 es casi imposible cambiar el contenido sin que se note. Pero ni siquiera ellas protegen de una persona que conoce el algoritmo y escribe una suma correcta nueva: resign en el programa hace justo eso. Solo una firma con clave secreta (HMAC) lo detectaría, y en un juego para un jugador en su propio ordenador no hay clave secreta ni sentido en tal protección. La suma aquí es para daños y ediciones torpes. Quien quiera hacer trampa en un juego de un jugador lo hará, y es asunto suyo.

Versiones y migraciones. Cada cambio en el formato es una versión nueva. Tres caminos: rechazar (nuestra carga: «versión 2, pero el juego lee la versión 1»), leer también las versiones viejas con una conversión incorporada (el ejercicio de abajo) o leerlas todas para siempre. Lo último crece: tras años la carga tiene código para una docena de versiones. Por eso a menudo se hace una cadena de «migraciones»: versión 1 → 2 → 3, cada paso pequeño y con una prueba.

Una escritura cortada a medias. Se va la luz mientras se escribe el archivo: en el disco queda medio archivo. La suma lo detecta (falta la última línea, «falta checksum»), pero la partida del jugador se pierde igualmente. Es mejor escribir en un archivo temporal y renombrarlo sobre el viejo solo cuando está escrito del todo; un renombrado es una única operación. Nuestro io.save escribe directamente, porque la lección no trata de sistemas de archivos.

Por qué no JSON. JSON valdría y lo verás en otras lecciones. Aquí el texto clave=valor es deliberadamente simple: la salida debe ser igual en cuatro lenguajes sin bibliotecas, y todo el formato se lee en una pantalla. El precio es que las cosas anidadas (una bolsa, un enemigo con efectos) se pegan a mano con : y ,.

Un formato binario. Más pequeño y rápido, pero no se lee ni se compara a simple vista. Para un juego de texto con partidas de unos pocos kilobytes es innecesario.

Ejercicio

Versión 2: maná

Resuelve la tarea en tu editor. Parte del programa de la carga.

Maná y dos versiones. El héroe recibe maná (mana, 10 por defecto). La versión 2 del formato añade una línea hero.mana justo después de hero.hp. El juego ahora escribe la versión 2 pero aún lee partidas de la versión 1: no tienen maná y el héroe recibe el valor por defecto. La versión 3 (y superiores) se rechaza con el mismo mensaje de antes, y readValues acepta una versión entre 1 y la más nueva. Haz que loadGame devuelva null para los dos tipos de partida.

Aquí está la partida de la versión 1 (como la escribe el programa de la lección), la función auxiliar y el cuerpo:

// Una partida como la escribe la versión 1 del juego.
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"));

La salida es:

Salida
1: ok
Iva: nivel 3, vida 27, oro 42, maná 10
2: ok
Iva: nivel 3, vida 27, oro 42, maná 8
3: La partida está dañada: versión 3, pero el juego lee la versión 2. El juego sigue sin ella.
Iva: nivel 3, vida 27, oro 42, maná 8

Más tareas

  • Guardado automático. Escribe en una ranura aparte auto cada 100 turnos. ¿Cómo sabe el menú que la ranura es automática?
  • Un archivo cortado. Quita la última línea de una partida (la suma). ¿Qué dice la carga? Cambia io.save para que escriba en un archivo temporal y lo renombre.
  • El resumen de la ranura. Añade una línea summary= con el nombre y el nivel y una función que la lea sin cargar el mundo, como slotSummary en el canon.
  • La misma clave dos veces. Ahora el último hero.gold= gana en silencio, porque la tabla reemplaza. Haz que readValues rechace partidas con una clave repetida.

Test

Compruébate

1. Una partida es de la versión 2 y su suma de control no coincide, pero la cargas en un juego que lee la versión 1. ¿Qué error ve el jugador y por qué?
2. La suma de «ab» es 3105 y la de «ba» es 3135. ¿Por qué no es simplemente la suma de los códigos de los caracteres (195 para las dos cadenas)?
3. Alguien que sabe cómo se calcula la suma de control sube el oro a 99999 y escribe al final la suma del texto nuevo. ¿Qué pasa al cargar?
4. La carga lee todo en variables locales y solo después las pone en el juego. ¿Por qué?
5. El canon guarda también el estado del generador de números aleatorios (rng). ¿Por qué?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·