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:
- ¿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.
- ¿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.
- ¿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{ public string Name; public int Level; public int Hp; public int Gold; public int X; public int Y; public List<Slot> Inventory = new List<Slot>();
public Hero(string name, int level, int hp, int gold, int x, int y) { Name = name; Level = level; Hp = hp; Gold = gold; X = x; Y = y; }}
class Game{ public Hero Hero; public int Turn;
public Game(Hero hero, int turn) { Hero = hero; Turn = turn; }}
class SaveError : Exception{ public SaveError(string message) : base(message) { }}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):
const string SAVE_FORMAT = "ARCANUM";const 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.int Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[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.string JoinOrDash(List<string> parts){ return parts.Count == 0 ? "-" : string.Join(",", parts);}
string Sign(string body){ return body + "checksum=" + Checksum(body) + "\n";}
string SaveGame(Game game){ Hero hero = game.Hero; var lines = new List<string>(); lines.Add("format=" + SAVE_FORMAT); lines.Add("version=" + SAVE_VERSION); lines.Add("turn=" + game.Turn); lines.Add("hero.name=" + hero.Name); lines.Add("hero.level=" + hero.Level); lines.Add("hero.hp=" + hero.Hp); lines.Add("hero.gold=" + hero.Gold); lines.Add("hero.x=" + hero.X); lines.Add("hero.y=" + hero.Y); var bag = new List<string>(); foreach (Slot slot in hero.Inventory) bag.Add(slot.Id + ":" + slot.Count); lines.Add("hero.inventory=" + JoinOrDash(bag)); return Sign(string.Join("\n", lines) + "\n");}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):
var game = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);game.Hero.Inventory.Add(new Slot("potion_heal", 2));game.Hero.Inventory.Add(new Slot("leather", 1));Console.Write(SaveGame(game));
game.Hero.Inventory.Clear();string[] again = SaveGame(game).Split('\n');Console.WriteLine(again[9]);Console.WriteLine(again[10]);format=ARCANUMversion=1turn=212hero.name=Ivahero.level=3hero.hp=27hero.gold=42hero.x=5hero.y=2hero.inventory=potion_heal:2,leather:1checksum=624337035hero.inventory=-checksum=126903799La 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.int Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[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.int Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007; return (int)sum;}
Console.WriteLine(Checksum("ab"));Console.WriteLine(Checksum("ba"));«ab» es 97 · 31 + 98 = 3105 y «ba» es 98 · 31 + 97 = 3135: al cambiar dos letras, la suma es otra. La segunda respuesta daría la misma suma para cadenas distintas, y la tercera es la suma simple de los códigos (97 + 98 = 195), en la que el orden no se ve.
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.
EnChecksum 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.
string JoinOrDash(List<string> parts)
{
return parts.Count == 0 ? "" : string.Join(",", parts);
}Una lista vacía se escribe como un guion. Una cadena vacía daría una línea hero.inventory= sin valor: al leer, split de una cadena vacía devuelve un elemento vacío, y la carga vería un objeto sin nombre. El guion dice con claridad «no hay nada», y la carga lo lee como una señal deliberada.
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.bool IsNumber(string text){ if (text.Length == 0 || text.Length > 9) return false; foreach (char c in text) { if (c < '0' || c > '9') return false; } return true;}
string Need(Dictionary<string, string> values, string key){ if (!values.ContainsKey(key)) throw new SaveError("falta " + key); return values[key];}
int NeedNumber(Dictionary<string, string> values, string key){ string value = Need(values, key); if (!IsNumber(value)) throw new SaveError("valor incorrecto en " + key); return int.Parse(value);}
Dictionary<string, string> ReadValues(string text){ var lines = new List<string>(text.Split('\n')); if (lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError("esto no es una partida de Arcanum"); var values = new Dictionary<string, string>(); for (int i = 0; i < lines.Count; i++) { int eq = lines[i].IndexOf('='); if (eq <= 0) throw new SaveError("la línea " + (i + 1) + " no tiene la forma clave=valor"); values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Need(values, "version"); if (version != SAVE_VERSION.ToString()) throw new SaveError("versión " + version + ", pero el juego lee la versión " + SAVE_VERSION); string last = lines[lines.Count - 1]; if (!last.StartsWith("checksum=")) throw new SaveError("falta checksum"); string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n"; int actual = Checksum(body); string stored = last.Substring(9); if (stored != actual.ToString()) throw new SaveError("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:
Hero ReadHero(Dictionary<string, string> values){ string name = Need(values, "hero.name"); int level = NeedNumber(values, "hero.level"); int hp = NeedNumber(values, "hero.hp"); int gold = NeedNumber(values, "hero.gold"); int x = NeedNumber(values, "hero.x"); int y = NeedNumber(values, "hero.y"); var hero = new Hero(name, level, hp, gold, x, y); string bag = Need(values, "hero.inventory"); if (bag != "-") { foreach (string part in bag.Split(',')) { string[] pieces = part.Split(':'); if (pieces.Length != 2 || !IsNumber(pieces[1])) throw new SaveError("valor incorrecto en hero.inventory"); hero.Inventory.Add(new Slot(pieces[0], int.Parse(pieces[1]))); } } return hero;}
// Devuelve null si todo fue bien; si no, el texto del problema.string LoadGame(Game game, string text){ try { var 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.Message + ". 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:
var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.Hero.Inventory.Add(new Slot("potion_heal", 2));iva.Hero.Inventory.Add(new Slot("leather", 1));string text = SaveGame(iva);
var copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);string error = LoadGame(copy, text);Console.WriteLine(error == null ? "ok" : error);Console.WriteLine(HeroLine(copy.Hero));Console.WriteLine(SaveGame(copy) == text ? "Volver a escribir la partida da el mismo texto." : "Volver a escribir la partida da un texto distinto.");okIva: nivel 3, vida 27, oro 42Volver 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.
int.Parse 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') deja un último elemento vacío tras el salto de línea final; ReadValues lo quita. Las funciones son locales del programa y comparten las constantes que tienen encima.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.int Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007; return (int)sum;}
string Sign(string body){ return body + "checksum=" + Checksum(body) + "\n";}
string SaveGame(Game game){ Hero hero = game.Hero; var lines = new List<string>(); lines.Add("format=" + SAVE_FORMAT); lines.Add("version=" + SAVE_VERSION); lines.Add("turn=" + game.Turn); lines.Add("hero.name=" + hero.Name); lines.Add("hero.level=" + hero.Level); lines.Add("hero.hp=" + hero.Hp); lines.Add("hero.gold=" + hero.Gold); lines.Add("hero.x=" + hero.X); lines.Add("hero.y=" + hero.Y); var bag = new List<string>(); foreach (Slot slot in hero.Inventory) bag.Add(slot.Id + ":" + slot.Count); lines.Add("hero.inventory=" + JoinOrDash(bag)); return Sign(string.Join("\n", lines) + "\n");}
Dictionary<string, string> ReadValues(string text){ var lines = new List<string>(text.Split('\n')); if (lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError("esto no es una partida de Arcanum"); var values = new Dictionary<string, string>(); for (int i = 0; i < lines.Count; i++) { int eq = lines[i].IndexOf('='); if (eq <= 0) throw new SaveError("la línea " + (i + 1) + " no tiene la forma clave=valor"); values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Need(values, "version"); if (version != SAVE_VERSION.ToString()) throw new SaveError("versión " + version + ", pero el juego lee la versión " + SAVE_VERSION); string last = lines[lines.Count - 1]; if (!last.StartsWith("checksum=")) throw new SaveError("falta checksum"); string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n"; int actual = Checksum(body); string stored = last.Substring(9); if (stored != actual.ToString()) throw new SaveError("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.string LoadGame(Game game, string text){ try { var 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.Message + ". El juego sigue sin ella."; }}Paso a paso, en palabras
- 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.
- 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í.
- 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.
- 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.
- 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ó.
- 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.
- 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.
- 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.
- 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.string Resign(string text){ return Sign(text.Substring(0, text.LastIndexOf("checksum=")));}
string HeroLine(Hero hero){ return hero.Name + ": nivel " + hero.Level + ", vida " + hero.Hp + ", oro " + hero.Gold;}var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.Hero.Inventory.Add(new Slot("potion_heal", 2));iva.Hero.Inventory.Add(new Slot("leather", 1));string good = SaveGame(iva);
var broken = new List<string>();// 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"));
var game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);for (int i = 0; i < broken.Count; i++){ Console.WriteLine((i + 1) + ": " + LoadGame(game, broken[i]));}Console.WriteLine(HeroLine(game.Hero));// La partida entera entra.Console.WriteLine(LoadGame(game, good) == null ? "ok" : "no");Console.WriteLine(HeroLine(game.Hero));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 7okIva: nivel 3, vida 27, oro 42Cada 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:
public static long Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) sum = (sum * 31 + text[i]) % 1000000007; return sum;}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:
public static string SaveGame(Game game){ List<string> lines = new List<string>(); Action<string, object> put = (key, value) => lines.Add(key + "=" + value); Hero hero = game.Hero; put("format", SAVE_FORMAT); put("version", SAVE_VERSION); put("summary", T("slot.summary", ("name", hero.Name), ("heroClass", Nm(hero.HeroClass())), ("level", hero.Level), ("place", PlaceName(game)))); put("seed", game.Seed); put("rng", game.Rng.State); put("ids", game.Ids.Next); put("turn", game.Turn); put("depth", game.Depth); put("victory", game.Victory ? 1 : 0); put("hero.name", hero.Name); put("hero.class", hero.ClassId); put("hero.level", hero.Level); put("hero.xp", hero.Xp); put("hero.gold", hero.Gold); put("hero.hp", hero.Hp); put("hero.mana", hero.Mana); put("hero.x", hero.X); put("hero.y", hero.Y); List<int> baseStats = new List<int>(); foreach (string stat in STAT_NAMES) baseStats.Add(hero.Base[stat]); put("hero.base", string.Join(",", baseStats)); put("hero.skills", JoinOrDash(hero.Skills)); put("hero.cooldowns", CooldownsText(hero)); put("hero.effects", EffectsText(hero)); List<string> equipment = new List<string>(); foreach (string slot in hero.Equipment.Keys) equipment.Add(hero.Equipment.Get(slot) == "" ? "-" : hero.Equipment.Get(slot)); put("hero.equipment", string.Join(",", equipment)); List<string> inventory = new List<string>(); foreach (ItemStack slot in hero.Inventory.Slots) inventory.Add(slot.Id + ":" + slot.Count); put("hero.inventory", JoinOrDash(inventory)); Stats stats = game.Stats; put("stats", string.Join(",", new int[] { stats.Turns, stats.Kills, stats.Blood, stats.Dealt, stats.Taken, stats.Potions })); put("flags", JoinOrDash(game.Flags)); foreach (Quest quest in game.Quests) put("quest." + quest.Id, quest.State + ":" + quest.Stage); foreach (Level level in game.Levels) { string prefix = "level." + level.Depth + "."; for (int y = 0; y < level.Height; y++) { string seen = ""; string decor = ""; for (int x = 0; x < level.Width; x++) { seen += level.Seen[y][x] ? "1" : "0"; decor += level.Decor[y][x] == ' ' ? '_' : level.Decor[y][x]; } put(prefix + "tiles." + y, new string(level.Tiles[y])); put(prefix + "seen." + y, seen); put(prefix + "decor." + y, decor); } List<string> items = new List<string>(); foreach (FloorItem item in level.Items) items.Add(item.X + ":" + item.Y + ":" + item.Id + ":" + item.Count); put(prefix + "items", JoinOrDash(items)); put(prefix + "enemies", level.Enemies.Count); for (int i = 0; i < level.Enemies.Count; i++) { Enemy enemy = level.Enemies[i]; put(prefix + "enemy." + i, string.Join(",", new object[] { enemy.Kind.Id, enemy.X, enemy.Y, enemy.Hp, enemy.State, enemy.Calm, enemy.LostTurns, enemy.Phase })); put(prefix + "enemy." + i + ".effects", EffectsText(enemy)); put(prefix + "enemy." + i + ".cooldowns", CooldownsText(enemy)); } List<string> npcs = new List<string>(); foreach (Npc npc in level.Npcs) npcs.Add(npc.Id + ":" + npc.X + ":" + npc.Y); put(prefix + "npcs", JoinOrDash(npcs)); } string body = string.Join("\n", lines) + "\n"; return body + "checksum=" + Checksum(body) + "\n";}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á:
class SaveReader{ public Dictionary<string, string> Values;
public SaveReader(string text) { Values = new Dictionary<string, string>(); List<string> lines = new List<string>(text.Split('\n')); while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1); if (lines.Count == 0 || lines[0] != "format=" + SAVE_FORMAT) throw new SaveError(T("load.badHeader")); for (int i = 0; i < lines.Count; i++) { int eq = lines[i].IndexOf('='); if (eq <= 0) throw new SaveError(T("load.badLine", ("line", i + 1))); Values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Text("version"); if (version != SAVE_VERSION.ToString()) throw new SaveError(T("load.badVersion", ("found", version), ("expected", SAVE_VERSION))); string last = lines[lines.Count - 1]; if (!last.StartsWith("checksum=", StringComparison.Ordinal)) throw new SaveError(T("load.missing", ("key", "checksum"))); string body = string.Join("\n", lines.GetRange(0, lines.Count - 1)) + "\n"; long actual = Checksum(body); if (ParseIntStrict(last.Substring(9)) != actual) { throw new SaveError(T("load.badChecksum", ("expected", last.Substring(9)), ("actual", actual))); } }
public string Text(string key) { if (!Values.ContainsKey(key)) throw new SaveError(T("load.missing", ("key", key))); return Values[key]; }
// Diez dígitos no caben en un int: el valor se recorta a 32 bits, como con >>> 0. public int Int(string key) { long? value = ParseIntStrict(Text(key)); if (value == null) throw new SaveError(T("load.badValue", ("key", key))); return unchecked((int)value.Value); }
public List<int> Ints(string key, int count) { string[] parts = Text(key).Split(','); if (parts.Length != count) throw new SaveError(T("load.badValue", ("key", key))); List<int> result = new List<int>(); foreach (string part in parts) { long? value = ParseIntStrict(part); if (value == null) throw new SaveError(T("load.badValue", ("key", key))); result.Add(unchecked((int)value.Value)); } return result; }
public List<string> List(string key) { return SplitList(Text(key)); }}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.public static string? LoadGame(Game game, string? text){ if (text == null) return T("load.empty"); try { SaveReader reader = new SaveReader(text); IdSource ids = new IdSource(1); Hero hero = ReadHero(reader, ids); List<Level> levels = new List<Level>(); foreach (MapDef def in MAP_DEFS) levels.Add(ReadLevel(reader, def, ids)); List<Quest> quests = CreateQuests(); foreach (Quest quest in quests) { string[] pieces = reader.Text("quest." + quest.Id).Split(':'); if (pieces.Length != 2) throw new SaveError(T("load.badValue", ("key", "quest." + quest.Id))); quest.State = pieces[0]; quest.Stage = ToInt(pieces[1]); } List<int> stats = reader.Ints("stats", 6); int depth = reader.Int("depth"); if (depth < 0 || depth > levels.Count) throw new SaveError(T("load.badValue", ("key", "depth"))); // 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 = new Stats { Turns = stats[0], Kills = stats[1], Blood = stats[2], Dealt = stats[3], Taken = stats[4], Potions = stats[5] }; game.Depth = depth; game.Turn = reader.Int("turn"); game.Victory = reader.Int("victory") == 1; game.Rng.State = unchecked((uint)reader.Int("rng")); game.Ids = new IdSource(Math.Max(reader.Int("ids"), ids.Next)); game.Messages = new List<string>(); if (depth > 0) ComputeFov(game.Level()!, hero.X, hero.Y); return null; } catch (SaveError error) { return T("load.broken", ("reason", error.Message)); } catch (Exception) { // 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 conio.load(ranura). En Node son archivos en una carpetasaves, en el navegador una entrada enlocalStorage. La partida en sí no sabe adónde va. - Una ranura dañada para probar. Con
--memory-saveslas partidas están en memoria ymainpone en la ranura 3 la partida deTamperedSave(), en el idioma del juego: una partida conhero.gold=99999y 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.
slotSummarylee solo la líneasummary=, sin cargar el mundo: así el menú muestra «Iva, bárbaro, nv.4, Vadogris» para cada ranura. - Tras cargar.
loadGamevacía los mensajes y calcula de nuevo lo visible concomputeFov, yenterWorlddice «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:
var iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.Hero.Inventory.Add(new Slot("potion_heal", 2));iva.Hero.Inventory.Add(new Slot("leather", 1));var game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
string text = Resign(SaveGame(iva).Replace("hero.level=3", "hero.level=three"));Console.WriteLine(LoadGame(game, text));Console.WriteLine(HeroLine(game.Hero));La partida está dañada: valor incorrecto en hero.level. El juego sigue sin ella.Iva: nivel 1, vida 10, oro 7La 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.
La primera asignación a game (el nombre) ocurre justo después de readValues, antes de que se haya leído el nivel. Cuando falla needNumber del nivel, el nombre ya está cambiado y el juego está a medio cargar. Lo correcto es leer todo en variables locales (como hace readHero) y asignar al final, cuando ya nada puede fallar.
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.string V1_SAVE = string.Join("\n", "format=ARCANUM", "version=1", "turn=212", "hero.name=Iva", "hero.level=3", "hero.hp=27", "hero.gold=42", "hero.x=5", "hero.y=2", "hero.inventory=potion_heal:2,leather:1", "checksum=624337035") + "\n";
void Check(Game game, int number, string text){ string error = LoadGame(game, text); Console.WriteLine(number + ": " + (error == null ? "ok" : error)); Console.WriteLine(HeroLine(game.Hero));}
var source = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);source.Hero.Inventory.Add(new Slot("potion_heal", 2));source.Hero.Inventory.Add(new Slot("leather", 1));source.Hero.Mana = 8;string v2 = SaveGame(source);
var game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);Check(game, 1, V1_SAVE);Check(game, 2, v2);Check(game, 3, v2.Replace("version=2", "version=3"));La salida es:
1: okIva: nivel 3, vida 27, oro 42, maná 102: okIva: nivel 3, vida 27, oro 42, maná 83: 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á 8Más tareas
- Guardado automático. Escribe en una ranura aparte
autocada 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.savepara 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, comoslotSummaryen el canon. - La misma clave dos veces. Ahora el último
hero.gold=gana en silencio, porque la tabla reemplaza. Haz quereadValuesrechace partidas con una clave repetida.
Test
Compruébate
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lanzar excepciones y crear las tuyas · Listas · Dictionary y HashSet · record, switch y patrones
- Ejercicios sobre el tema: Las crónicas de Arcanum