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.
class Slot { constructor(id, count) { this.id = id; this.count = count; }}
class Hero { constructor(name, level, hp, gold, x, y) { this.name = name; this.level = level; this.hp = hp; this.gold = gold; this.x = x; this.y = y; this.inventory = []; }}
class Game { constructor(hero, turn) { this.hero = hero; this.turn = turn; }}
class SaveError extends Error {}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 SAVE_FORMAT = "ARCANUM";const SAVE_VERSION = 1;
// La suma sale de los caracteres de todo lo que hay encima; si alguien cambia una sola letra, ya no coincide.function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return sum;}
// Una lista vacía se escribe como un guion, para que la línea nunca tenga un valor vacío.function joinOrDash(parts) { return parts.length === 0 ? "-" : parts.join(",");}
function sign(body) { return body + "checksum=" + checksum(body) + "\n";}
function saveGame(game) { const hero = game.hero; const lines = []; lines.push("format=" + SAVE_FORMAT); lines.push("version=" + SAVE_VERSION); lines.push("turn=" + game.turn); lines.push("hero.name=" + hero.name); lines.push("hero.level=" + hero.level); lines.push("hero.hp=" + hero.hp); lines.push("hero.gold=" + hero.gold); lines.push("hero.x=" + hero.x); lines.push("hero.y=" + hero.y); const bag = []; for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count); lines.push("hero.inventory=" + joinOrDash(bag)); return sign(lines.join("\n") + "\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):
const game = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);game.hero.inventory.push(new Slot("potion_heal", 2));game.hero.inventory.push(new Slot("leather", 1));process.stdout.write(saveGame(game));
game.hero.inventory.length = 0;const again = saveGame(game).split("\n");console.log(again[9]);console.log(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.function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return 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.function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return sum;}
console.log(checksum("ab"));console.log(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.
Los números de JavaScript sondouble, pero sum * 31 queda por debajo de 2^53 (unos 31 · 10^9), así que la cuenta es exacta. charCodeAt(i) es el código del carácter.// Una lista vacía se escribe como un guion, para que la línea nunca tenga un valor vacío.
function joinOrDash(parts) {
return parts.length === 0 ? "" : parts.join(",");
}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.function isNumber(text) { if (text.length === 0 || text.length > 9) return false; for (const c of text) { if (c < "0" || c > "9") return false; } return true;}
function need(values, key) { if (!values.has(key)) throw new SaveError(`falta ${key}`); return values.get(key);}
function needNumber(values, key) { const value = need(values, key); if (!isNumber(value)) throw new SaveError(`valor incorrecto en ${key}`); return Number(value);}
function readValues(text) { const lines = text.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("esto no es una partida de Arcanum"); const values = new Map(); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf("="); if (eq <= 0) throw new SaveError(`la línea ${i + 1} no tiene la forma clave=valor`); values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = need(values, "version"); if (version !== String(SAVE_VERSION)) throw new SaveError(`versión ${version}, pero el juego lee la versión ${SAVE_VERSION}`); const last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError(`falta checksum`); const body = lines.slice(0, lines.length - 1).join("\n") + "\n"; const actual = checksum(body); const stored = last.substring(9); if (stored !== String(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:
function readHero(values) { const name = need(values, "hero.name"); const level = needNumber(values, "hero.level"); const hp = needNumber(values, "hero.hp"); const gold = needNumber(values, "hero.gold"); const x = needNumber(values, "hero.x"); const y = needNumber(values, "hero.y"); const hero = new Hero(name, level, hp, gold, x, y); const bag = need(values, "hero.inventory"); if (bag !== "-") { for (const part of bag.split(",")) { const pieces = part.split(":"); if (pieces.length !== 2 || !isNumber(pieces[1])) throw new SaveError(`valor incorrecto en hero.inventory`); hero.inventory.push(new Slot(pieces[0], Number(pieces[1]))); } } return hero;}
// Devuelve null si todo fue bien; si no, el texto del problema.function loadGame(game, text) { try { const values = readValues(text); const hero = readHero(values); const 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 (error) { if (!(error instanceof SaveError)) throw 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:
const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.hero.inventory.push(new Slot("potion_heal", 2));iva.hero.inventory.push(new Slot("leather", 1));const text = saveGame(iva);
const copy = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);const error = loadGame(copy, text);console.log(error === null ? "ok" : error);console.log(heroLine(copy.hero));console.log(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.
Number("") es 0 y Number(" 7") es 7, así que isNumber comprueba por su cuenta que solo hay cifras (como mucho nueve). catch (error) en JavaScript captura todo, así que loadGame devuelve solo nuestros SaveError y vuelve a lanzar cualquier error ajeno: un fallo del código no debe disfrazarse de «partida dañada».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.function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return sum;}
function sign(body) { return body + "checksum=" + checksum(body) + "\n";}
function saveGame(game) { const hero = game.hero; const lines = []; lines.push("format=" + SAVE_FORMAT); lines.push("version=" + SAVE_VERSION); lines.push("turn=" + game.turn); lines.push("hero.name=" + hero.name); lines.push("hero.level=" + hero.level); lines.push("hero.hp=" + hero.hp); lines.push("hero.gold=" + hero.gold); lines.push("hero.x=" + hero.x); lines.push("hero.y=" + hero.y); const bag = []; for (const slot of hero.inventory) bag.push(slot.id + ":" + slot.count); lines.push("hero.inventory=" + joinOrDash(bag)); return sign(lines.join("\n") + "\n");}
function readValues(text) { const lines = text.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); if (lines.length === 0 || lines[0] !== "format=" + SAVE_FORMAT) throw new SaveError("esto no es una partida de Arcanum"); const values = new Map(); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf("="); if (eq <= 0) throw new SaveError(`la línea ${i + 1} no tiene la forma clave=valor`); values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = need(values, "version"); if (version !== String(SAVE_VERSION)) throw new SaveError(`versión ${version}, pero el juego lee la versión ${SAVE_VERSION}`); const last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError(`falta checksum`); const body = lines.slice(0, lines.length - 1).join("\n") + "\n"; const actual = checksum(body); const stored = last.substring(9); if (stored !== String(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.function loadGame(game, text) { try { const values = readValues(text); const hero = readHero(values); const 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 (error) { if (!(error instanceof SaveError)) throw 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.function resign(text) { return sign(text.substring(0, text.lastIndexOf("checksum=")));}
function heroLine(hero) { return hero.name + ": nivel " + hero.level + ", vida " + hero.hp + ", oro " + hero.gold;}const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.hero.inventory.push(new Slot("potion_heal", 2));iva.hero.inventory.push(new Slot("leather", 1));const good = saveGame(iva);
const broken = [];// Alguien subió el oro a mano: la suma es la vieja.broken.push(good.replace("hero.gold=42", "hero.gold=99999"));// El archivo de otro juego.broken.push(good.replace("format=ARCANUM", "format=SOLITAIRE"));// Escrita por una versión más nueva del juego.broken.push(good.replace("version=1", "version=2"));// Falta una línea, la suma se calcula de nuevo.broken.push(resign(good.replace("hero.gold=42\n", "")));// Un número malo, la suma se calcula de nuevo.broken.push(resign(good.replace("hero.level=3", "hero.level=three")));// Una línea sin signo igual.broken.push(good.replace("hero.hp=27", "hero.hp 27"));
const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);for (let i = 0; i < broken.length; i++) { console.log((i + 1) + ": " + loadGame(game, broken[i]));}console.log(heroLine(game.hero));// La partida entera entra.console.log(loadGame(game, good) === null ? "ok" : "no");console.log(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:
function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(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:
function saveGame(game) { const lines = []; const put = (key, value) => lines.push(key + '=' + value); const 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); const base = []; for (const stat of STAT_NAMES) base.push(hero.base.get(stat)); put('hero.base', base.join(',')); put('hero.skills', joinOrDash(hero.skills)); put('hero.cooldowns', cooldownsText(hero)); put('hero.effects', effectsText(hero)); const equipment = []; for (const slot of hero.equipment.keys()) equipment.push(hero.equipment.get(slot) === '' ? '-' : hero.equipment.get(slot)); put('hero.equipment', equipment.join(',')); const inventory = []; for (const slot of hero.inventory.slots) inventory.push(slot.id + ':' + slot.count); put('hero.inventory', joinOrDash(inventory)); const stats = game.stats; put('stats', [stats.turns, stats.kills, stats.blood, stats.dealt, stats.taken, stats.potions].join(',')); put('flags', joinOrDash(game.flags)); for (const quest of game.quests) put('quest.' + quest.id, quest.state + ':' + quest.stage); for (const level of game.levels) { const prefix = 'level.' + level.depth + '.'; for (let y = 0; y < level.height; y++) { let seen = ''; let decor = ''; for (let 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, level.tiles[y].join('')); put(prefix + 'seen.' + y, seen); put(prefix + 'decor.' + y, decor); } const items = []; for (const item of level.items) items.push(item.x + ':' + item.y + ':' + item.id + ':' + item.count); put(prefix + 'items', joinOrDash(items)); put(prefix + 'enemies', level.enemies.length); for (let i = 0; i < level.enemies.length; i++) { const enemy = level.enemies[i]; put(prefix + 'enemy.' + i, [enemy.kind.id, enemy.x, enemy.y, enemy.hp, enemy.state, enemy.calm, enemy.lostTurns, enemy.phase].join(',')); put(prefix + 'enemy.' + i + '.effects', effectsText(enemy)); put(prefix + 'enemy.' + i + '.cooldowns', cooldownsText(enemy)); } const npcs = []; for (const npc of level.npcs) npcs.push(npc.id + ':' + npc.x + ':' + npc.y); put(prefix + 'npcs', joinOrDash(npcs)); } const body = lines.join('\n') + '\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 { constructor(text) { this.values = new Map(); const lines = text.split('\n'); while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); if (lines.length === 0 || lines[0] !== 'format=' + SAVE_FORMAT) throw new SaveError(t('load.badHeader')); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf('='); if (eq <= 0) throw new SaveError(t('load.badLine', { line: i + 1 })); this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = this.text('version'); if (version !== String(SAVE_VERSION)) throw new SaveError(t('load.badVersion', { found: version, expected: SAVE_VERSION })); const last = lines[lines.length - 1]; if (!last.startsWith('checksum=')) throw new SaveError(t('load.missing', { key: 'checksum' })); const body = lines.slice(0, lines.length - 1).join('\n') + '\n'; const actual = checksum(body); if (parseIntStrict(last.substring(9)) !== actual) { throw new SaveError(t('load.badChecksum', { expected: last.substring(9), actual: actual })); } }
text(key) { if (!this.values.has(key)) throw new SaveError(t('load.missing', { key: key })); return this.values.get(key); }
int(key) { const value = parseIntStrict(this.text(key)); if (value === null) throw new SaveError(t('load.badValue', { key: key })); return value; }
ints(key, count) { const parts = this.text(key).split(','); if (parts.length !== count) throw new SaveError(t('load.badValue', { key: key })); const result = []; for (const part of parts) { const value = parseIntStrict(part); if (value === null) throw new SaveError(t('load.badValue', { key: key })); result.push(value); } return result; }
list(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.function loadGame(game, text) { if (text === null) return t('load.empty'); try { const reader = new SaveReader(text); const ids = new IdSource(1); const hero = readHero(reader, ids); const levels = []; for (const def of MAP_DEFS) levels.push(readLevel(reader, def, ids)); const quests = createQuests(); for (const quest of quests) { const 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]); } const stats = reader.ints('stats', 6); const depth = reader.int('depth'); if (depth < 0 || depth > levels.length) 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 = { 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') >>> 0; game.ids = new IdSource(Math.max(reader.int('ids'), ids.next)); game.messages = []; if (depth > 0) computeFov(game.level(), hero.x, hero.y); return null; } catch (error) { // Hasta «solo ahora se toca el juego» el código solo lee: cualquier error que aparezca viene de los datos. const reason = error instanceof SaveError ? error.message : t('load.badData'); return t('load.broken', { reason: reason }); }}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:
const iva = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);iva.hero.inventory.push(new Slot("potion_heal", 2));iva.hero.inventory.push(new Slot("leather", 1));const game = new Game(new Hero("Boris", 1, 10, 7, 0, 0), 0);
const text = resign(saveGame(iva).replace("hero.level=3", "hero.level=three"));console.log(loadGame(game, text));console.log(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.const V1_SAVE = [ "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",].join("\n") + "\n";
function check(game, number, text) { const error = loadGame(game, text); console.log(number + ": " + (error === null ? "ok" : error)); console.log(heroLine(game.hero));}
const source = new Game(new Hero("Iva", 3, 27, 42, 5, 2), 212);source.hero.inventory.push(new Slot("potion_heal", 2));source.hero.inventory.push(new Slot("leather", 1));source.hero.mana = 8;const v2 = saveGame(source);
const 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 · Arrays: push, splice y sort · Map y Set · Datos, switch y desestructuración
- Ejercicios sobre el tema: Las crónicas de Arcanum