La estrategia completa
En resumen: El juego termina con la primera de cinco comprobaciones que dice «sí»: conquista, derrota, ciencia, economía o el plazo (puntos). Un guardado es texto «clave=valor» con versión y suma de control, la carga lo comprueba todo antes de tocar el juego, y el equilibrio se mide con cientos de partidas sin pantalla.
Problema
¿Cuándo termina el juego?
Durante cinco lecciones construimos piezas: una galaxia a partir de una semilla, un árbol de tecnologías, flotas y batallas, rivales con carácter y un turno con fases y sucesos. Pero el juego aún no tiene final. Si nadie gana nunca, la partida no termina, y si la victoria está mal pensada, el jugador juega ciento cincuenta turnos y descubre que todo se decidió en los primeros veinte.
Esta lección cierra el módulo con las tres cosas sobre las que se sostiene todo juego completo: la victoria (cuándo y cómo termina una partida), el guardado (cómo se continúa mañana) y el equilibrio (cómo sabes que el juego es justo sin jugarlo mil veces a mano). Después, el juego está aquí, entero.
Así termina la partida grabada con la que el sitio comprueba el juego en los cuatro lenguajes. El Imperio Terrano investiga la Puerta Estelar en el turno 59, y la pantalla final muestra la clasificación:
FIN DEL JUEGO
Imperio Terrano investigó la Puerta Estelar en el turno 59.
Clasificación (planetas, tecnologías, puntos):A Imperio Terrano 7 13 376C Unión de Lira 0 3 38 destruidoD Academia de Vega 7 12 319B Hegemonía de Orión 2 8 154
Batallas 27, unidades perdidas 159, colonias 12, tecnologías36, sucesos 27.
(Enter: salir)En esta pantalla se ven tres cosas: el tipo de victoria (ciencia), la clasificación (planetas, tecnologías y puntos de cada imperio; la Unión de Lira está destruida) y las estadísticas de la partida. Salen de tres funciones pequeñas que verás aquí.
La idea
Cuatro victorias, una cadena de comprobaciones
El juego tiene cuatro maneras de terminar y una más para que nunca se cuelgue:
- Conquista: solo queda vivo un imperio.
- Derrota: el jugador está destruido (la partida termina y el más fuerte de los demás se anota como ganador).
- Ciencia: algún imperio vivo ha investigado la Puerta Estelar, la tecnología del final del árbol.
- Economía: algún imperio vivo ha producido 9800 en total.
- Plazo: llega el turno 150 y gana quien tiene más puntos.
Los puntos de un imperio son 10 por planeta, 6 por tecnología, la potencia de la flota dividida entre 10 y la producción dividida entre 40. Este es el código real del juego:
// Tres victorias: conquista (queda un solo imperio), ciencia (la Puerta Estelar investigada) y// economía (producción ECON_GOAL acumulada). Si nadie gana hasta MAX_TURNS, gana el// que tenga más puntos. Si el jugador es destruido, el juego termina con una derrota.static partial class Cosmos{ public static int EmpireScore(World world, Empire empire) { return OwnedPlanets(world, empire.Id).Count * 10 + empire.Techs.Count * 6 + EmpirePower(world, empire.Id) / 10 + empire.Produced / 40; }
public static Empire BestScoring(World world) { Empire? best = null; int bestScore = -1; foreach (Empire e in world.Empires) { if (!e.Alive) continue; int score = EmpireScore(world, e); if (score > bestScore) { bestScore = score; best = e; } } return best!; }
public static void CheckVictory(World world) { if (world.Victory != null) return; if (world.LivingCount() == 1) { foreach (Empire e in world.Empires) { if (e.Alive) world.Victory = new Victory { Empire = e.Id, Kind = "conquest", Turn = world.Turn }; } return; } foreach (Empire e in world.Empires) { if (e.Human && !e.Alive) { world.Victory = new Victory { Empire = BestScoring(world).Id, Kind = "lost", Turn = world.Turn }; return; } } foreach (Empire e in world.Empires) { if (e.Alive && Knows(e, "stargate")) { world.Victory = new Victory { Empire = e.Id, Kind = "science", Turn = world.Turn }; return; } } foreach (Empire e in world.Empires) { if (e.Alive && e.Produced >= ECON_GOAL) { world.Victory = new Victory { Empire = e.Id, Kind = "economy", Turn = world.Turn }; return; } } if (world.Turn >= MAX_TURNS) world.Victory = new Victory { Empire = BestScoring(world).Id, Kind = "points", Turn = world.Turn }; }}checkVictory no es una comprobación, sino cinco en un orden exacto, y la primera que dice «sí» decide. Es una salida temprana: en cuanto sabes la respuesta, retornas. El orden no es casual. Si solo queda un imperio vivo, no tiene sentido preguntar por la puerta. Si el jugador está destruido, la partida está perdida por mucho que alguien haya producido. Y el plazo va el último porque es la victoria más débil.
El programa pequeño hace lo mismo con las mismas reglas, pero con tres imperios y sin galaxia. Un imperio sabe cuántos puntos tiene:
class Empire{ public string Name; public bool Human; public bool Alive = true; public bool Stargate = false; public int Planets; public int Techs; public int Power; public int Produced;
public Empire(string name, bool human, int planets, int techs, int power, int produced) { Name = name; Human = human; Planets = planets; Techs = techs; Power = power; Produced = produced; }
public int Score() { return Planets * 10 + Techs * 6 + Power / 10 + Produced / 40; }}class Victory{ public string Kind; public string Winner;
public Victory(string kind, string winner) { Kind = kind; Winner = winner; }}Las comprobaciones son las mismas cinco, en el mismo orden (ECON_GOAL es 9800, MAX_TURNS es 150):
static Victory? CheckVictory(List<Empire> empires, int turn){ int living = 0; Empire? last = null; foreach (Empire e in empires) { if (e.Alive) { living++; last = e; } } if (living == 1) return new Victory("conquista", last!.Name); foreach (Empire e in empires) { if (e.Human && !e.Alive) return new Victory("derrota", BestScoring(empires)!.Name); } foreach (Empire e in empires) { if (e.Alive && e.Stargate) return new Victory("ciencia", e.Name); } foreach (Empire e in empires) { if (e.Alive && e.Produced >= EconGoal) return new Victory("economía", e.Name); } if (turn >= MaxTurns) return new Victory("puntos", BestScoring(empires)!.Name); return null;}Siete mundos, siete respuestas. Los dos últimos muestran lo más importante: un jugador destruido da derrota, y el turno 150 sin otra victoria da puntos.
var game = Fresh();Show("turno 40, nada ha terminado", game, 40);
game = Fresh();game[2].Stargate = true;Show("Puerta Estelar investigada", game, 40);
game = Fresh();game[1].Produced = 9800;Show("9800 producidos", game, 40);
game = Fresh();game[2].Stargate = true;game[2].Produced = 9800;Show("la puerta y 9800 en el mismo turno", game, 40);
game = Fresh();game[1].Alive = false;game[2].Alive = false;Show("queda un solo imperio", game, 40);
game = Fresh();game[0].Alive = false;Show("el jugador está destruido", game, 40);
Show("turno 150, sin otra victoria", Fresh(), 150);turno 40, nada ha terminado: -Puerta Estelar investigada: ciencia (Academia de Vega)9800 producidos: economía (Hegemonía de Orión)la puerta y 9800 en el mismo turno: ciencia (Academia de Vega)queda un solo imperio: conquista (Imperio Terrano)el jugador está destruido: derrota (Hegemonía de Orión)turno 150, sin otra victoria: puntos (Imperio Terrano)Cada caso parte de la misma partida nueva y cambia una o dos cosas. El cuarto es el engañoso: la Academia de Vega ha investigado la Puerta Estelar y ha producido 9800 en el mismo turno. Gana la ciencia, porque va antes en el orden.
Míralo
El primer «sí» decide
Cuatro de estos casos en movimiento. Fíjate en qué comprobaciones se ejecutan y cuáles se oscurecen porque nunca se llega a ellas:
static Victory? CheckVictory(List<Empire> empires, int turn){ int living = 0; Empire? last = null; foreach (Empire e in empires) { if (e.Alive) { living++; last = e; } } if (living == 1) return new Victory("conquista", last!.Name); foreach (Empire e in empires) { if (e.Human && !e.Alive) return new Victory("derrota", BestScoring(empires)!.Name); } foreach (Empire e in empires) { if (e.Alive && e.Stargate) return new Victory("ciencia", e.Name); } foreach (Empire e in empires) { if (e.Alive && e.Produced >= EconGoal) return new Victory("economía", e.Name); } if (turn >= MaxTurns) return new Victory("puntos", BestScoring(empires)!.Name); return null;}Paso a paso, en palabras
- Tras cada turno el mundo hace cinco preguntas en orden: si queda un solo imperio, si el jugador está destruido, si hay Puerta Estelar, si alguien produjo 9800, si es el turno 150. El primer «sí» decide.
- Turno 40. Los tres imperios siguen vivos, nadie tiene la Puerta Estelar, nadie llegó a 9800.
- Cinco veces «no». checkVictory devuelve null y el juego pasa al siguiente turno.
- Academia de Vega: la Puerta Estelar está investigada y se produjeron 9800, las dos cosas en el mismo turno. Dos victorias a la vez.
- La tercera comprobación (ciencia) dice «sí» y retorna de inmediato. La cuarta, la economía, no se alcanza: el orden de las comprobaciones decide quién gana.
- Hegemonía de Orión y Academia de Vega están destruidos. Queda un solo imperio: Imperio Terrano.
- La primera comprobación dice «sí»: conquista. Las cuatro siguientes ni se ejecutan.
- Turno 150: el plazo. Nadie ha ganado de otra forma.
- Cuatro «no» y al final el plazo: gana quien tiene más puntos. Imperio Terrano y Hegemonía de Orión empatan, y el empate lo gana el primero.
¿Por qué el primer «sí» retorna de inmediato? Porque después no queda nada por decidir: una partida tiene una victoria. Pero también hay una razón práctica. Si la cuarta comprobación se ejecutara después de la tercera, la función tendría que elegir entre dos victorias, y eso es una regla nueva. Con la salida temprana la regla es el orden, y se lee directamente en el código: intercambias dos líneas y has cambiado quién gana cuando las condiciones coinciden.
Más precisamente, para curiosos
Aquí la victoria se comprueba una vez por turno, cuando todo lo demás del turno ha terminado. Así un jugador que llegó a 9800 no gana a mitad del turno, antes de que los rivales hayan jugado el suyo. En la región turn del canon, checkVictory(world) es la última fase del turno, después de la producción, la investigación y el suceso.
El juego guarda la victoria en el mundo (world.victory) en lugar de devolverla: la pantalla final, el guardado y la simulación leen el mismo sitio. Así la misma comprobación sirve para un jugador y para una simulación sin pantalla.
La idea
Los puntos: empates y destruidos
El plazo es la más simple de las cuatro, pero tiene dos reglas ocultas. Primero, los imperios destruidos no cuentan: un imperio con muchos puntos que ya ha desaparecido no debe ganar. Segundo, el empate: dos imperios con los mismos puntos necesitan un ganador predecible, o la simulación no es determinista.
static Empire? BestScoring(List<Empire> empires){ Empire? best = null; int bestScore = -1; foreach (Empire e in empires) { if (!e.Alive) continue; int score = e.Score(); if (score > bestScore) { bestScore = score; best = e; } } return best;}bestScore empieza en −1 para que hasta un imperio con cero puntos pueda ser el líder. La comparación es estricta (>), por eso en un empate se queda el primero. El programa compara cuatro imperios, uno destruido y dos con los mismos puntos:
var rivals = new List<Empire>();rivals.Add(new Empire("Imperio Terrano", false, 5, 9, 400, 3000));rivals.Add(new Empire("Hegemonía de Orión", false, 3, 11, 450, 3120));rivals.Add(new Empire("Academia de Vega", false, 4, 8, 300, 2000));var lyra = new Empire("Unión de Lira", false, 8, 14, 900, 6000);lyra.Alive = false;rivals.Add(lyra);
foreach (Empire e in rivals){ string note = e.Alive ? "" : " (destruido)"; Console.WriteLine(e.Name + ": " + e.Score() + note);}Console.WriteLine("Más puntos: " + BestScoring(rivals)!.Name);Imperio Terrano: 219Hegemonía de Orión: 219Academia de Vega: 168Unión de Lira: 404 (destruido)Más puntos: Imperio TerranoLa Unión de Lira tiene más puntos (404), pero está destruida y queda fuera. El Imperio Terrano y la Hegemonía de Orión están ambos en 219, y el ganador es el primero. Las divisiones son enteras, como en todo el juego (/ sobre enteros en Java y C#), y por eso los puntos son iguales en los cuatro lenguajes.
La idea
Guardar: texto «clave=valor»
Quien cierra el juego en el turno 80 quiere continuar mañana desde el turno 80. Guardar es volver a leer el mundo, y tiene tres preguntas: qué se guarda, en qué forma y cómo se sabe que el archivo está entero.
Qué. No todo. La galaxia (sistemas, rutas, tipos de planeta) se hace a partir de la semilla: la misma semilla, la misma galaxia (lección 1 del módulo). Por eso un guardado conserva solo lo que cambia: de quién es cada planeta, qué está construido, los recursos, las tecnologías, las flotas, la cola de construcción, la paz y la guerra y el estado del generador, para que los dados continúen desde el mismo punto.
En qué forma. Texto, línea a línea, clave=valor. Se lee de un vistazo, se compara con otro guardado, se arregla a mano en un caso extremo, y el formato no está atado al lenguaje del juego: el mismo texto lo puede escribir y leer una versión en cualquier lenguaje. La primera línea es la cabecera (format=COSMOS), la segunda es la versión del formato y la última es la suma de control de todo lo anterior.
public static string SaveWorld(World world){ List<string> lines = new List<string>(); void Put(string key, object value) { lines.Add(key + "=" + value); } Put("format", SAVE_FORMAT); Put("version", SAVE_VERSION); Put("summary", SaveSummary(world)); Put("seed", world.Seed); Put("rng", world.Rng.State); Put("turn", world.Turn); Put("systems", world.Systems.Count); Put("planets", world.Planets.Count); Put("empires", world.Empires.Count); foreach (Planet planet in world.Planets) { List<int> values = new List<int> { planet.Owner, planet.Mines, planet.Lab, planet.Yard, planet.Fort, planet.Siege }; values.AddRange(planet.Rich); Put("planet." + planet.Id, string.Join(",", values)); } foreach (Empire empire in world.Empires) { string prefix = "empire." + empire.Id; List<object> head = new List<object> { empire.Def.Id, empire.Personality.Id, empire.Alive ? 1 : 0, empire.Progress, empire.Produced, empire.Flare }; foreach (int amount in empire.Res) head.Add(amount); Put(prefix, string.Join(",", head)); Put(prefix + ".researching", empire.Researching == "" ? "-" : empire.Researching); Put(prefix + ".techs", JoinOrDash(empire.Techs)); Put(prefix + ".war", string.Join(",", empire.War)); Put(prefix + ".since", string.Join(",", empire.Since)); Put(prefix + ".grudge", string.Join(",", empire.Grudge)); List<string> orders = new List<string>(); foreach (Order order in empire.Orders) { List<int> values = new List<int> { order.From, order.To, order.Dest }; values.AddRange(order.Ships); orders.Add(string.Join(":", values)); } Put(prefix + ".orders", JoinOrDash(orders)); } Put("fleets", world.Fleets.Count); for (int i = 0; i < world.Fleets.Count; i++) { Fleet fleet = world.Fleets[i]; List<int> values = new List<int> { fleet.Owner, fleet.SystemId }; values.AddRange(fleet.Ships); Put("fleet." + i, string.Join(",", values)); } Put("queue", world.Queue.Count); for (int i = 0; i < world.Queue.Count; i++) { QueueItem item = world.Queue[i]; Put("queue." + i, string.Join(",", new int[] { item.Owner, item.PlanetId, item.Kind, item.Count, item.Left })); } List<string> offers = new List<string>(); foreach (Offer offer in world.Offers) offers.Add(offer.From + ":" + offer.To + ":" + offer.Turn); Put("offers", JoinOrDash(offers)); WorldStats s = world.Stats; Put("stats", string.Join(",", new int[] { s.Battles, s.Lost, s.Colonies, s.Techs, s.Events })); string body = string.Join("\n", lines) + "\n"; return body + "checksum=" + Checksum(body) + "\n";}El programa pequeño guarda lo mismo en miniatura: una semilla, un turno y tres imperios (planetas, producido, tecnologías), con versión y suma de control al final:
class Game{ public int Seed; public int Turn; public List<int[]> Empires = new List<int[]>();
public Game(int seed, int turn) { Seed = seed; Turn = turn; }}static string Save(Game state){ var lines = new List<string>(); lines.Add("format=COSMOS"); lines.Add("version=" + Version); lines.Add("seed=" + state.Seed); lines.Add("turn=" + state.Turn); lines.Add("empires=" + state.Empires.Count); for (int i = 0; i < state.Empires.Count; i++) { int[] e = state.Empires[i]; lines.Add("empire." + i + "=" + e[0] + "," + e[1] + "," + e[2]); } string body = string.Join("\n", lines) + "\n"; return body + "checksum=" + Checksum(body) + "\n";}Console.Write(Save(Sample()));format=COSMOSversion=1seed=2026turn=12empires=3empire.0=5,3120,9empire.1=4,2750,7empire.2=3,4180,10checksum=987468948Ese es el guardado, línea a línea. La versión está ahí no para hoy sino para mañana: cuando el formato cambie (una clave nueva, otro orden), los guardados antiguos deben reconocerse y rechazarse o convertirse. Sin versión, un guardado de una versión vieja se leería mal y en silencio.
La idea
Suma de control: un cambio y la suma no coincide
La última línea de un guardado es el número que sale de todo lo anterior. Al cargar, el juego lo calcula de nuevo y lo compara con el guardado: si el archivo está cortado, estropeado o tocado a mano, los números difieren. El algoritmo es pequeño:
// La suma pasa de 32 bits (suma por 31 y signo), por eso es un long.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;}Para cada carácter la suma se multiplica por 31, se añade el código del carácter y se toma el resto módulo 1000000007. Lo importante es la multiplicación por 31: hace que el orden cuente. Una simple suma de códigos daría el mismo número para «ab» y «ba», y dos dígitos intercambiados en el guardado son justo el error que queremos detectar.
string[] texts = {"ab", "ba", "turn=12", "turn=21"};foreach (string word in texts){ Console.WriteLine(word + " -> " + Checksum(word));}ab -> 3105ba -> 3135turn=12 -> 408655515turn=21 -> 408655545«ab» es 97 × 31 + 98 = 3105, y «ba» es 98 × 31 + 97 = 3135. El resto mantiene el número por debajo de mil millones para que quepa en 32 bits, y long guarda el resultado intermedio sum * 31, que supera los dos mil millones.
static long Checksum(string text)
{
long sum = 0;
for (int i = 0; i < text.Length; i++)
{
sum = (sum * 31 + text[i]) 1000000007;
}
return sum;
}El resto (%) devuelve la suma por debajo de 1000000007 después de cada carácter. Sin él, el número crecería tanto como largo sea el guardado.
Más precisamente, para curiosos
Una suma de control detecta errores, no manipulación. Quien conoce la fórmula cambia un valor y recalcula la suma (el programa de abajo hace justo eso con reseal, para probar sus otras defensas). Para un juego guardado en tu propio disco basta: el objetivo es no cargar medio guardado. Para una clasificación competitiva en un servidor hace falta otra cosa: una firma con clave secreta (HMAC) que el jugador no pueda calcular.
El algoritmo es de la familia de las «sumas polinómicas», la misma que hashCode de una cadena en Java. Para hashes de verdad (SHA-256) hay bibliotecas hechas, pero el juego mantiene una comprobación de 32 bits sin nada de fuera: se lee de un vistazo y da los mismos números en los cuatro lenguajes.
La idea
Una carga que no se fía de nadie
Un guardado viene del disco, y el disco puede devolver cualquier cosa: un archivo cortado, un guardado de otra versión del juego, un archivo editado en el bloc de notas. Si el juego lo lee a ciegas, un guardado malo lo hace caer o (peor) lo deja en un estado a medias que solo se nota en el turno 90. Por eso la carga lo comprueba todo antes de tocar el juego y dice exactamente qué falla.
class SaveReader{ public Dictionary<string, string> Values = new Dictionary<string, string>();
public SaveReader(string text) { 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]; }
// Un número dentro de los límites [low, high]. public long Int(string key, long low, long high) { long? value = ParseIntStrict(Text(key)); if (value == null || value < low || value > high) throw new SaveError(T("load.badValue", ("key", key))); return value.Value; }
// Una lista de exactamente count números, cada uno dentro de sus límites. public int[] Ints(string key, int count, int low, int high) { string[] parts = Text(key).Split(','); if (parts.Length != count) throw new SaveError(T("load.badValue", ("key", key))); int[] result = new int[count]; for (int i = 0; i < parts.Length; i++) { long? value = ParseIntStrict(parts[i]); if (value == null || value < low || value > high) throw new SaveError(T("load.badValue", ("key", key))); result[i] = (int)value.Value; } return result; }
public List<string> List(string key) { return SplitList(Text(key)); }}El orden de las comprobaciones importa: la cabecera, que cada línea sea clave=valor, la versión, la suma de control y solo entonces los valores. Cada valor se lee con int o ints, que comprueban los rangos (un turno de 1 a 150, un número de imperios de 1 a 9). La versión pequeña es la misma en miniatura:
En Java y C# las funciones están en la clase del programa (Main y Program), y SaveReader es una clase aparte que las llama con el nombre de la clase. En el canon todo está en clases anidadas de una sola clase y el nombre no hace falta.
class SaveReader{ Dictionary<string, string> values = new Dictionary<string, string>();
public SaveReader(string text) { var 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=COSMOS") throw new SaveError("esto no es una partida de Epic Cosmos"); 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 es \"clave=valor\""); values[lines[i].Substring(0, eq)] = lines[i].Substring(eq + 1); } string version = Text("version"); if (version != Program.Version.ToString()) throw new SaveError("versión " + version + ", pero el juego lee la versión " + Program.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"; long actual = Program.Checksum(body); if (Program.Digits(last.Substring(9)) != actual) { throw new SaveError("suma de control incorrecta (guardada " + last.Substring(9) + ", calculada " + actual + "), archivo modificado"); } }
public string Text(string key) { if (!values.ContainsKey(key)) throw new SaveError("falta \"" + key + "\""); return values[key]; }
public int Integer(string key, int low, int high) { long value = Program.Digits(Text(key)); if (value < low || value > high) throw new SaveError("valor incorrecto de \"" + key + "\""); return (int)value; }
public int[] Numbers(string key, int count, int low, int high) { string[] parts = Text(key).Split(','); if (parts.Length != count) throw new SaveError("valor incorrecto de \"" + key + "\""); var result = new int[count]; for (int i = 0; i < count; i++) { long value = Program.Digits(parts[i]); if (value < low || value > high) throw new SaveError("valor incorrecto de \"" + key + "\""); result[i] = (int)value; } return result; }}static Game Load(string text){ var reader = new SaveReader(text); var state = new Game(reader.Integer("seed", 0, 99999), reader.Integer("turn", 1, MaxTurns)); int count = reader.Integer("empires", 1, 9); for (int i = 0; i < count; i++) { state.Empires.Add(reader.Numbers("empire." + i, 3, 0, 99999)); } return state;}load crea una partida nueva a partir de lo leído y la devuelve, sin tocar la actual. El error es una excepción SaveError que lleva las palabras para el jugador. Quien llama la captura y la muestra, y el juego sigue con el mundo anterior:
static void ShowLoad(string label, string text){ try { Game loaded = Load(text); int produced = 0; foreach (int[] e in loaded.Empires) produced += e[1]; Console.WriteLine(label + ": " + "turno " + loaded.Turn + ", imperios " + loaded.Empires.Count + ", producido " + produced); } catch (SaveError error) { Console.WriteLine(label + ": " + error.Message); }}Seis guardados, seis respuestas. Dos de ellos están alterados para que la suma pase (por eso hacen falta la comprobación de rangos y de claves), y uno está tocado a mano y la suma lo detecta:
string good = Save(Sample());string body = good.Substring(0, good.IndexOf("checksum="));ShowLoad("partida intacta", good);ShowLoad("editada a mano", good.Replace("3120", "99999"));ShowLoad("otra versión", good.Replace("version=1", "version=2"));ShowLoad("número fuera de rango", Reseal(body.Replace("turn=12", "turn=999")));ShowLoad("falta una línea", Reseal(body.Replace("seed=2026\n", "")));ShowLoad("no es una partida", "hello");partida intacta: turno 12, imperios 3, producido 10050editada a mano: suma de control incorrecta (guardada 987468948, calculada 410613307), archivo modificadootra versión: versión 2, pero el juego lee la versión 1número fuera de rango: valor incorrecto de "turn"falta una línea: falta "seed"no es una partida: esto no es una partida de Epic CosmosEl juego real hace lo mismo. En la ranura 3 de la partida grabada hay un guardado estropeado a propósito, como si alguien hubiera subido los recursos a mano, y el juego lo rechaza sin caerse y sin tocar la partida actual:
> slotsPartidas guardadas (save N, load N)Ranura 1: Turno 5, Imperio Terrano, 2 planetasRanura 2: vacíaRanura 3: Turno 99, sin guardia> load 3La partida está dañada: suma de control incorrecta (guardada123456, calculada 607314851), archivo modificado.Por último el canon une las dos cosas: loadWorld captura SaveError y devuelve o un mundo nuevo o un mensaje. Así la pantalla nunca ve un juego roto.
// Devuelve { world, error }: o el mundo nuevo, o un mensaje de qué falla.public static LoadResult LoadWorld(string text){ try { return new LoadResult { World = ReadWorld(text), Error = null }; } catch (SaveError error) { return new LoadResult { World = null, Error = error.Message }; }}La idea
Equilibrio: cómo sabes que el juego es justo
El juego tiene tres caracteres de IA: señor de la guerra (valora el ataque), constructor (la expansión y la construcción) y erudito (la ciencia y la Puerta Estelar): un carácter es un conjunto de pesos para las decisiones del imperio. ¿Son justos? Si uno gana el 80 % de las partidas, los demás son decoración. Mil partidas a mano no las juega nadie, pero un ordenador las hace en segundos: juega contra sí mismo.
--simulate=200 juega doscientas partidas sin pantalla, IA contra IA. Todo lo que ayuda: el juego no imprime por sí mismo (la pantalla es aparte), el azar tiene semilla (el resultado es repetible) y los caracteres rotan por los asientos:
// El equilibrio: cientos de partidas sin pantalla, inteligencia artificial contra inteligencia artificial. Los caracteres// rotan por los asientos, de modo que cada uno juega igual de a menudo en cada posición; la tabla dice cuántas// partidas gana cada carácter y cómo terminan las partidas.static partial class Cosmos{ public static readonly string[] PERSONALITY_IDS = { "warlord", "builder", "scholar" }; public static readonly string[] VICTORY_KINDS = { "conquest", "science", "economy", "points" };
public static void Simulate(IGameIo io, int games, long seed) { io.Write(T("sim.title", ("games", games), ("seed", seed)) + "\n"); io.Write(T("sim.note1", ("turns", MAX_TURNS), ("goal", ECON_GOAL)) + "\n\n"); int[] played = { 0, 0, 0 }; int[] wins = { 0, 0, 0 }; int[] winTurns = { 0, 0, 0 }; int[] kinds = { 0, 0, 0, 0 }; int totalTurns = 0; int totalSystems = 0; for (int i = 0; i < games; i++) { List<string> order = new List<string>(); for (int k = 0; k < 4; k++) order.Add(PERSONALITY_IDS[(i + k) % 3]); World world = NewWorld(seed + i, false, order, true); totalSystems += world.Systems.Count; while (world.Victory == null) RunTurn(world); foreach (Empire empire in world.Empires) played[empire.Personality.Index]++; Empire winner = world.Empires[world.Victory.Empire]; wins[winner.Personality.Index]++; winTurns[winner.Personality.Index] += world.Victory.Turn; kinds[Array.IndexOf(VICTORY_KINDS, world.Victory.Kind)]++; totalTurns += world.Victory.Turn; } io.Write(Pad(T("sim.personality"), 20) + PadLeft(T("sim.played"), 10) + PadLeft(T("sim.wins"), 10) + PadLeft(T("sim.share"), 8) + PadLeft(T("sim.avgTurn"), 12) + "\n"); foreach (Personality personality in PERSONALITIES.Values()) { int i = personality.Index; int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i]; io.Write(Pad(Cap(Nm(personality)), 20) + PadLeft(played[i].ToString(), 10) + PadLeft(wins[i].ToString(), 10) + PadLeft(Percent(wins[i], played[i]) + "%", 8) + PadLeft(avg.ToString(), 12) + "\n"); } List<string> parts = new List<string>(); for (int k = 0; k < VICTORY_KINDS.Length; k++) parts.Add(T("victory." + VICTORY_KINDS[k]) + " " + kinds[k]); io.Write("\n" + T("sim.kinds", ("list", string.Join(", ", parts))) + "\n"); io.Write(T("sim.averages", ("turns", totalTurns / games), ("systems", totalSystems / games)) + "\n"); }}La rotación (i + k) % 3 da a cada carácter cada asiento con la misma frecuencia. Si no, el primer asiento (en un empate gana el anterior) subiría el porcentaje del que siempre va primero.
Una versión pequeña de la misma idea. Tres caracteres con el mismo ingreso medio, 30 por turno, pero distinta dispersión: el señor de la guerra tiene suerte y fallos, el constructor es regular. El primero en llegar a 600 gana:
static int[] PlayGame(Random rng, int[] seats){ var produced = new int[seats.Length]; int turn = 0; while (true) { turn++; for (int k = 0; k < seats.Length; k++) { int p = seats[k]; produced[k] += BaseIncome[p] + rng.NextInt(Spread[p]); } for (int k = 0; k < seats.Length; k++) { if (produced[k] >= Goal) return new[] { seats[k], turn }; } }}int games = 200;var rng = new Random(7);var played = new int[3];var wins = new int[3];var winTurns = new int[3];int totalTurns = 0;for (int i = 0; i < games; i++){ var seats = new int[4]; for (int k = 0; k < 4; k++) { seats[k] = (i + k) % 3; played[seats[k]]++; } int[] result = PlayGame(rng, seats); wins[result[0]]++; winTurns[result[0]] += result[1]; totalTurns += result[1];}
string[] names = { "Señor de la guerra", "Constructor", "Erudito" };Console.WriteLine("Simulación de equilibrio: " + games + " partidas, semilla 7, meta " + Goal + ".");Console.WriteLine();Console.WriteLine(Pad("Carácter", 20) + PadLeft("Partidas", 10) + PadLeft("Victorias", 10) + PadLeft("Cuota", 8) + PadLeft("Turno med.", 12));for (int i = 0; i < 3; i++){ int share = wins[i] * 100 / played[i]; int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i]; Console.WriteLine(Pad(names[i], 20) + PadLeft("" + played[i], 10) + PadLeft("" + wins[i], 10) + PadLeft(share + "%", 8) + PadLeft("" + avg, 12));}Console.WriteLine();Console.WriteLine("Turno medio de la victoria: " + totalTurns / games + ".");Simulación de equilibrio: 200 partidas, semilla 7, meta 600.
Carácter Partidas Victorias Cuota Turno med.Señor de la guerra 267 79 29% 19Constructor 267 46 17% 20Erudito 266 75 28% 20
Turno medio de la victoria: 19.El ingreso medio es el mismo, y aun así el constructor gana solo el 17 % frente al 29 % del señor de la guerra y el 28 % del erudito. ¿Por qué? En una carrera hasta una meta gana el que en algún momento va por delante, no el de mejor media. Un jugador regular nunca está lejos del centro, pero rara vez es el primero; uno disperso a veces se despega. Por eso la media no basta, y por eso se simula: el número sale del propio juego.
Y esta es la simulación real de Epic Cosmos: doscientas partidas, semilla 7, de simulate.bg.out. El archivo forma parte de la comprobación, igual que la partida grabada:
| Carácter | Partidas | Victorias | Cuota | Turno medio |
|---|---|---|---|---|
| Señor de la guerra | 223 | 61 | 27% | 47 |
| Constructor | 220 | 59 | 26% | 57 |
| Erudito | 213 | 80 | 37% | 56 |
Victorias por tipo: conquista 50, ciencia 90, economía 60, puntos 0. El final medio de una partida es en el turno 53, y una galaxia tiene en promedio 15 sistemas.
¿Qué dice la tabla? El erudito gana más a menudo (37%), el señor de la guerra y el constructor van cerca. Puntos: 0 de 200. Ninguna partida llega al turno 150, porque alguien conquista, investiga la puerta o produce 9800 mucho antes. El plazo es una red de seguridad, no una victoria que elija la IA. Es una buena señal: los puntos son para una partida en la que nadie hace nada.
El equilibrio se ajusta con los números de los datos (precio de una tecnología, ingreso de una mina, fuerza de una nave), y tras cada cambio vuelves a lanzar la simulación. Si el erudito está en el 37 %, pruebas una Puerta Estelar más cara y miras cómo se mueve la tabla.
Más precisamente, para curiosos
La simulación es también un test del juego. Dos ejecuciones de doscientas partidas con la misma semilla deben dar el mismo archivo, y esa es la comprobación: simulate.bg.out se compara byte a byte en los cuatro lenguajes. Si un port tiene una diferencia en un dado, la partida toma otro camino y la tabla difiere. Así, una tabla de cientos de partidas detecta errores a los que la partida grabada de 192 comandos no llega.
Pruébalo
Juega
Este es el juego entero, la versión JavaScript del canon, ejecutada en la página. Eres el Imperio Terrano; el mapa muestra los sistemas y las rutas entre ellos. Hay tres maneras de ganar: investigar la Puerta Estelar, producir 9800 o dejar que los rivales se destruyan entre sí. Cuanto antes mejor, pero el plazo es el turno 150.
Epic Cosmos: el imperio
Cuatro imperios, una galaxia y ciento cincuenta turnos. La victoria es ciencia, economía o conquista.
Escribe los comandos en la línea de abajo y pulsa Enter (help los muestra todos; una línea vacía es map). Las partidas guardadas se quedan en este navegador.
Comandos (escríbelos en la línea bajo la pantalla): help los muestra todos, map es el mapa, sys 3 es el sistema 3, tech es el árbol de tecnologías, r stargate investiga la Puerta Estelar, b mine 2 construye una mina en el planeta 2, ship i 2 1 construye dos interceptores, go 1 3 envía la flota, emp muestra los imperios, y end y wait 3 terminan el turno (o hasta tres turnos). save 1, load 1 y slots trabajan con los guardados: se quedan en el navegador.
Prueba todo lo de la lección: guarda en la ranura 1, juega un poco más, carga la ranura 1 y comprueba que has vuelto atrás. Mira emp: cuántos puntos tiene cada uno. ¿Te entran ganas de otro precio para una tecnología? Eso es el equilibrio.
La idea
Ejecútalo en tu ordenador
El archivo entero está aquí: [Cosmos.java](/games/cosmos/es/Cosmos.java), [Cosmos.cs](/games/cosmos/es/Cosmos.cs), [cosmos.py](/games/cosmos/es/cosmos.py), [cosmos.js](/games/cosmos/es/cosmos.js). Ponlo en una carpeta vacía y ejecútalo:
dotnet new console -n Cosmoscd Cosmos# cambia Program.cs por Cosmos.csdotnet runCon --plain imprime los fotogramas como texto simple, con --seed=42 la galaxia es siempre la misma, con --lang=es está en español (hay en, de y es). La simulación que has visto es --simulate=200 (con --seed=7 da la misma tabla). Los guardados son archivos en una carpeta saves junto al juego, y --memory-saves los guarda en memoria.
Main en Cosmos.El juego está escrito cuatro veces con los mismos nombres y da byte a byte la misma salida en todos los lenguajes. La comprobación del sitio ejecuta cada versión con la partida grabada (192 comandos) y con la simulación, y las compara con un solo archivo. Así puedes leer el juego en el lenguaje que conoces y estar seguro de que es el mismo.
La idea
Ideas para continuar
El juego es tuyo: tienes todo el código. Aquí hay cosas del tamaño justo, y cada una usa algo de esta lección.
- Una victoria diplomática. Una comprobación nueva en
checkVictory: paz con todos los imperios vivos durante 20 turnos seguidos. ¿Dónde va en el orden? ¿Antes o después de la economía? Añádela y lanza la simulación para ver cuántas partidas acaban así. - Autoguardado. En cada
end, guarda en la ranura 0. ¿Qué pasa si el juego se cae a mitad de un guardado? (Pista: escribe en un archivo temporal y renómbralo.) - Una versión nueva del guardado. Añade una clave (por ejemplo
difficulty) y sube la versión a 2. La carga debe leer también la versión 1 antigua, poniendo un valor por defecto. Así pasa de versión en versión todo juego. - Una partida como lista de comandos. La semilla y la lista de comandos son la partida entera: es justo la partida grabada con la que compruebas el juego. Haz
replay <archivo>. - Equilibrio a tu gusto. Lanza
--simulate=1000con distintos números de precio de tecnología e ingreso y anota en una tabla qué es lo más justo. - Otra galaxia. Mapas desde un archivo en lugar de desde una semilla, o una galaxia mayor (las rutas a escala, el grafo de la primera lección).
- Jugador contra jugador por red. Los comandos ya son texto, una línea cada uno: los movimientos pueden viajar por un socket. El juego no cambia, solo de dónde viene la línea.
- Un lenguaje nuevo. Pasa el juego a Go, Rust o Kotlin y lanza la misma partida grabada: la salida debe ser el mismo archivo.
Pruébalo
Predice la salida
¿Qué imprime el programa?
static long Checksum(string text){ long sum = 0; for (int i = 0; i < text.Length; i++) { sum = (sum * 31 + text[i]) % 1000000007; } return sum;}
Console.WriteLine(Checksum("ab") + ", " + Checksum("ba") + ", " + Checksum("abc"));«ab» es 97 × 31 + 98 = 3105, «ba» es 98 × 31 + 97 = 3135 y «abc» es 3105 × 31 + 99 = 96354. La segunda posibilidad sería una suma de control que no distingue el orden (pero esta multiplica por 31, así que «ab» y «ba» son distintos). La tercera es la suma de los códigos (195, 195, 294): así saldría sin la multiplicación por 31.
Pruébalo
Encuentra el error
Un compañero quiso «arreglar» una línea de bestScoring. Los dos imperios tienen los mismos puntos y el programa imprime que gana el último (Hegemonía de Orión). La regla es que gane el primero. ¿Qué línea es la culpable?
Elige la línea con el error.
>= en lugar de >: en un empate el nuevo pasa a ser el líder, así que gana el último de los iguales, no el primero. Con > la comparación es estricta y un empate no desplaza al líder. El error no se ve con puntos distintos, por eso cuesta encontrarlo: aparece solo en un empate.
Ejercicio
Una suma de control para cada línea
Resuelve la tarea en tu editor.
Una suma de control para cada línea. El programa lee líneas hasta el final de la entrada y para cada línea no vacía imprime la línea, un espacio y su suma de control (con la fórmula de la lección, resto módulo 1000000007).
Con la entrada turn=12 | turn=13 | seed=2026 | COSMOS (una por línea) la salida es:
turn=12 408655515turn=13 408655516seed=2026 801283199COSMOS 993660451Comprueba con «ab» y «ba»: 3105 y 3135. Cuidado con el resultado intermedio sum * 31: en Java y C# debe ser long, o desborda con una línea más larga.
Más tareas
- Una victoria nueva. Añade una victoria diplomática al programa
victory(paz con todos los imperios vivos durante 20 turnos seguidos). ¿Dónde va en el orden? - Versión 2. Añade al guardado la clave
difficultyy subeVERSIONa 2. Haz queloadacepte también la versión 1 con valor por defecto 1. ¿Qué pasa con la suma de control de un guardado antiguo? - Equilibrio parejo. En el programa
balanceelige los números del constructor (ingreso y dispersión) para que los tres caracteres ganen entre el 30 % y el 36 % de las partidas, manteniendo el ingreso medio en 30. - Detectar una segunda entrada. ¿Qué pasa si dos líneas del guardado tienen la misma clave? Lee
SaveReader: ¿qué valor se queda? Escribe una comprobación que lo rechace.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Lanzar excepciones y crear las tuyas · Listas · Dictionary y HashSet
- Ejercicios sobre el tema: Epic Cosmos: el imperio