Ir al contenido
wannacode

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 376
C Unión de Lira 0 3 38 destruido
D Academia de Vega 7 12 319
B Hegemonía de Orión 2 8 154
Batallas 27, unidades perdidas 159, colonias 12, tecnologías
36, 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:

  1. Conquista: solo queda vivo un imperio.
  2. Derrota: el jugador está destruido (la partida termina y el más fuerte de los demás se anota como ganador).
  3. Ciencia: algún imperio vivo ha investigado la Puerta Estelar, la tecnología del final del árbol.
  4. Economía: algún imperio vivo ha producido 9800 en total.
  5. 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 int empireScore(World world, Empire empire) {
return ownedPlanets(world, empire.id).size() * 10 + empire.techs.size() * 6 + empirePower(world, empire.id) / 10 + empire.produced / 40;
}
static Empire bestScoring(World world) {
Empire best = null;
int bestScore = -1;
for (Empire e : world.empires) {
if (!e.alive) continue;
int score = empireScore(world, e);
if (score > bestScore) {
bestScore = score;
best = e;
}
}
return best;
}
static void checkVictory(World world) {
if (world.victory != null) return;
if (world.livingCount() == 1) {
for (Empire e : world.empires) {
if (e.alive) world.victory = new Victory(e.id, "conquest", world.turn);
}
return;
}
for (Empire e : world.empires) {
if (e.human && !e.alive) {
world.victory = new Victory(bestScoring(world).id, "lost", world.turn);
return;
}
}
for (Empire e : world.empires) {
if (e.alive && knows(e, "stargate")) {
world.victory = new Victory(e.id, "science", world.turn);
return;
}
}
for (Empire e : world.empires) {
if (e.alive && e.produced >= ECON_GOAL) {
world.victory = new Victory(e.id, "economy", world.turn);
return;
}
}
if (world.turn >= MAX_TURNS) world.victory = new Victory(bestScoring(world).id, "points", 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 {
final String name;
final boolean human;
boolean alive = true;
boolean stargate = false;
int planets;
int techs;
int power;
int produced;
Empire(String name, boolean human, int planets, int techs, int power, int produced) {
this.name = name;
this.human = human;
this.planets = planets;
this.techs = techs;
this.power = power;
this.produced = produced;
}
int score() {
return planets * 10 + techs * 6 + power / 10 + produced / 40;
}
}
class Victory {
final String kind;
final String winner;
Victory(String kind, String winner) {
this.kind = kind;
this.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;
for (Empire e : empires) {
if (e.alive) {
living++;
last = e;
}
}
if (living == 1) return new Victory("conquista", last.name);
for (Empire e : empires) {
if (e.human && !e.alive) return new Victory("derrota", bestScoring(empires).name);
}
for (Empire e : empires) {
if (e.alive && e.stargate) return new Victory("ciencia", e.name);
}
for (Empire e : empires) {
if (e.alive && e.produced >= ECON_GOAL) return new Victory("economía", e.name);
}
if (turn >= MAX_TURNS) 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.

List<Empire> game = fresh();
show("turno 40, nada ha terminado", game, 40);
game = fresh();
game.get(2).stargate = true;
show("Puerta Estelar investigada", game, 40);
game = fresh();
game.get(1).produced = ECON_GOAL;
show("9800 producidos", game, 40);
game = fresh();
game.get(2).stargate = true;
game.get(2).produced = ECON_GOAL;
show("la puerta y 9800 en el mismo turno", game, 40);
game = fresh();
game.get(1).alive = false;
game.get(2).alive = false;
show("queda un solo imperio", game, 40);
game = fresh();
game.get(0).alive = false;
show("el jugador está destruido", game, 40);
show("turno 150, sin otra victoria", fresh(), 150);
Salida
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;
for (Empire e : empires) {
if (e.alive) {
living++;
last = e;
}
}
if (living == 1) return new Victory("conquista", last.name);
for (Empire e : empires) {
if (e.human && !e.alive) return new Victory("derrota", bestScoring(empires).name);
}
for (Empire e : empires) {
if (e.alive && e.stargate) return new Victory("ciencia", e.name);
}
for (Empire e : empires) {
if (e.alive && e.produced >= ECON_GOAL) return new Victory("economía", e.name);
}
if (turn >= MAX_TURNS) return new Victory("puntos", bestScoring(empires).name);
return null;
}
Paso a paso, en palabras
  1. 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.
  2. Turno 40. Los tres imperios siguen vivos, nadie tiene la Puerta Estelar, nadie llegó a 9800.
  3. Cinco veces «no». checkVictory devuelve null y el juego pasa al siguiente turno.
  4. Academia de Vega: la Puerta Estelar está investigada y se produjeron 9800, las dos cosas en el mismo turno. Dos victorias a la vez.
  5. 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.
  6. Hegemonía de Orión y Academia de Vega están destruidos. Queda un solo imperio: Imperio Terrano.
  7. La primera comprobación dice «sí»: conquista. Las cuatro siguientes ni se ejecutan.
  8. Turno 150: el plazo. Nadie ha ganado de otra forma.
  9. 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;
for (Empire e : 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:

List<Empire> rivals = new ArrayList<>();
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));
Empire lyra = new Empire("Unión de Lira", false, 8, 14, 900, 6000);
lyra.alive = false;
rivals.add(lyra);
for (Empire e : rivals) {
String note = e.alive ? "" : " (destruido)";
System.out.println(e.name + ": " + e.score() + note);
}
System.out.println("Más puntos: " + bestScoring(rivals).name);
Salida
Imperio Terrano: 219
Hegemonía de Orión: 219
Academia de Vega: 168
Unión de Lira: 404 (destruido)
Más puntos: Imperio Terrano

La 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.

static String saveWorld(World world) {
List<String> lines = new ArrayList<>();
put(lines, "format", SAVE_FORMAT);
put(lines, "version", SAVE_VERSION);
put(lines, "summary", saveSummary(world));
put(lines, "seed", world.seed);
put(lines, "rng", world.rng.state);
put(lines, "turn", world.turn);
put(lines, "systems", world.systems.size());
put(lines, "planets", world.planets.size());
put(lines, "empires", world.empires.size());
for (Planet planet : world.planets) {
put(lines, "planet." + planet.id, joinInts(concat(new int[] {planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege}, planet.rich), ","));
}
for (Empire empire : world.empires) {
String prefix = "empire." + empire.id;
put(lines, prefix, empire.def.id + "," + empire.personality.id + "," + (empire.alive ? 1 : 0) + "," + empire.progress + "," + empire.produced + "," + empire.flare + "," + joinInts(empire.res, ","));
put(lines, prefix + ".researching", empire.researching.equals("") ? "-" : empire.researching);
put(lines, prefix + ".techs", joinOrDash(empire.techs));
put(lines, prefix + ".war", joinInts(empire.war, ","));
put(lines, prefix + ".since", joinInts(empire.since, ","));
put(lines, prefix + ".grudge", joinInts(empire.grudge, ","));
List<String> orders = new ArrayList<>();
for (Order order : empire.orders) orders.add(joinInts(concat(new int[] {order.from, order.to, order.dest}, order.ships), ":"));
put(lines, prefix + ".orders", joinOrDash(orders));
}
put(lines, "fleets", world.fleets.size());
for (int i = 0; i < world.fleets.size(); i++) {
Fleet fleet = world.fleets.get(i);
put(lines, "fleet." + i, joinInts(concat(new int[] {fleet.owner, fleet.systemId}, fleet.ships), ","));
}
put(lines, "queue", world.queue.size());
for (int i = 0; i < world.queue.size(); i++) {
QueueItem item = world.queue.get(i);
put(lines, "queue." + i, joinInts(new int[] {item.owner, item.planetId, item.kind, item.count, item.left}, ","));
}
List<String> offers = new ArrayList<>();
for (Offer offer : world.offers) offers.add(offer.from + ":" + offer.to + ":" + offer.turn);
put(lines, "offers", joinOrDash(offers));
Stats s = world.stats;
put(lines, "stats", joinInts(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 {
final int seed;
int turn;
final List<int[]> empires = new ArrayList<>();
Game(int seed, int turn) {
this.seed = seed;
this.turn = turn;
}
}
static String save(Game game) {
List<String> lines = new ArrayList<>();
lines.add("format=COSMOS");
lines.add("version=" + VERSION);
lines.add("seed=" + game.seed);
lines.add("turn=" + game.turn);
lines.add("empires=" + game.empires.size());
for (int i = 0; i < game.empires.size(); i++) {
int[] e = game.empires.get(i);
lines.add("empire." + i + "=" + e[0] + "," + e[1] + "," + e[2]);
}
String body = String.join("\n", lines) + "\n";
return body + "checksum=" + checksum(body) + "\n";
}
System.out.print(save(sample()));
Salida
format=COSMOS
version=1
seed=2026
turn=12
empires=3
empire.0=5,3120,9
empire.1=4,2750,7
empire.2=3,4180,10
checksum=987468948

Ese 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 mil millones: sum * 31 no cabe en un int, por eso es un long.
static long checksum(String text) {
long sum = 0;
for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007L;
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"};
for (String word : texts) {
System.out.println(word + " -> " + checksum(word));
}
Salida
ab -> 3105
ba -> 3135
turn=12 -> 408655515
turn=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.charAt(i))  1000000007;
    }
    return sum;
}
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.

static class SaveReader {
Map<String, String> values = new LinkedHashMap<>();
SaveReader(String text) {
List<String> lines = split(text, "\n");
while (lines.size() > 0 && lines.get(lines.size() - 1).length() == 0) lines.remove(lines.size() - 1);
if (lines.size() == 0 || !lines.get(0).equals("format=" + SAVE_FORMAT)) throw new SaveError(t("load.badHeader"));
for (int i = 0; i < lines.size(); i++) {
int eq = lines.get(i).indexOf('=');
if (eq <= 0) throw new SaveError(t("load.badLine", "line", i + 1));
this.values.put(lines.get(i).substring(0, eq), lines.get(i).substring(eq + 1));
}
String version = this.text("version");
if (!version.equals(String.valueOf(SAVE_VERSION))) throw new SaveError(t("load.badVersion", "found", version, "expected", SAVE_VERSION));
String last = lines.get(lines.size() - 1);
if (!last.startsWith("checksum=")) throw new SaveError(t("load.missing", "key", "checksum"));
String body = String.join("\n", lines.subList(0, lines.size() - 1)) + "\n";
long actual = checksum(body);
Long given = parseIntStrict(last.substring(9));
if (given == null || given != actual) {
throw new SaveError(t("load.badChecksum", "expected", last.substring(9), "actual", actual));
}
}
String text(String key) {
if (!this.values.containsKey(key)) throw new SaveError(t("load.missing", "key", key));
return this.values.get(key);
}
// Un número dentro de los límites [low, high]. Devuelve un long: la semilla llega hasta 4294967295.
long integer(String key, long low, long high) {
Long value = parseIntStrict(this.text(key));
if (value == null || value < low || value > high) throw new SaveError(t("load.badValue", "key", key));
return value;
}
// Una lista de exactamente count números, cada uno dentro de sus límites.
int[] ints(String key, int count, int low, int high) {
List<String> parts = split(this.text(key), ",");
if (parts.size() != count) throw new SaveError(t("load.badValue", "key", key));
int[] result = new int[count];
for (int i = 0; i < count; i++) {
Long value = parseIntStrict(parts.get(i));
if (value == null || value < low || value > high) throw new SaveError(t("load.badValue", "key", key));
result[i] = (int) (long) value;
}
return result;
}
List<String> list(String key) {
return splitList(this.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 {
final Map<String, String> values = new HashMap<>();
SaveReader(String text) {
List<String> lines = new ArrayList<>(Arrays.asList(text.split("\n", -1)));
while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) lines.remove(lines.size() - 1);
if (lines.isEmpty() || !lines.get(0).equals("format=COSMOS")) throw new SaveError("esto no es una partida de Epic Cosmos");
for (int i = 0; i < lines.size(); i++) {
int eq = lines.get(i).indexOf('=');
if (eq <= 0) throw new SaveError("la línea " + (i + 1) + " no es \"clave=valor\"");
values.put(lines.get(i).substring(0, eq), lines.get(i).substring(eq + 1));
}
String version = text("version");
if (!version.equals(String.valueOf(Main.VERSION))) throw new SaveError("versión " + version + ", pero el juego lee la versión " + Main.VERSION);
String last = lines.get(lines.size() - 1);
if (!last.startsWith("checksum=")) throw new SaveError("falta \"" + "checksum" + "\"");
String body = String.join("\n", lines.subList(0, lines.size() - 1)) + "\n";
long actual = Main.checksum(body);
if (Main.digits(last.substring(9)) != actual) {
throw new SaveError("suma de control incorrecta (guardada " + last.substring(9) + ", calculada " + actual + "), archivo modificado");
}
}
String text(String key) {
if (!values.containsKey(key)) throw new SaveError("falta \"" + key + "\"");
return values.get(key);
}
int integer(String key, int low, int high) {
long value = Main.digits(text(key));
if (value < low || value > high) throw new SaveError("valor incorrecto de \"" + key + "\"");
return (int) value;
}
int[] numbers(String key, int count, int low, int high) {
String[] parts = text(key).split(",", -1);
if (parts.length != count) throw new SaveError("valor incorrecto de \"" + key + "\"");
int[] result = new int[count];
for (int i = 0; i < count; i++) {
long value = Main.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) {
SaveReader reader = new SaveReader(text);
Game game = new Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS));
int count = reader.integer("empires", 1, 9);
for (int i = 0; i < count; i++) {
game.empires.add(reader.numbers("empire." + i, 3, 0, 99999));
}
return game;
}

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 game = load(text);
int produced = 0;
for (int[] e : game.empires) produced += e[1];
System.out.println(label + ": " + "turno " + game.turn + ", imperios " + game.empires.size() + ", producido " + produced);
} catch (SaveError error) {
System.out.println(label + ": " + error.getMessage());
}
}

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");
Salida
partida intacta: turno 12, imperios 3, producido 10050
editada a mano: suma de control incorrecta (guardada 987468948, calculada 410613307), archivo modificado
otra versión: versión 2, pero el juego lee la versión 1
nú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 Cosmos

El 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:

> slots
Partidas guardadas (save N, load N)
Ranura 1: Turno 5, Imperio Terrano, 2 planetas
Ranura 2: vacía
Ranura 3: Turno 99, sin guardia
> load 3
La partida está dañada: suma de control incorrecta (guardada
123456, 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.
static LoadResult loadWorld(String text) {
try {
return new LoadResult(readWorld(text), null);
} catch (SaveError error) {
return new LoadResult(null, error.getMessage());
}
}

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 final String[] PERSONALITY_IDS = {"warlord", "builder", "scholar"};
static final String[] VICTORY_KINDS = {"conquest", "science", "economy", "points"};
static void simulate(Io 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++) {
String[] order = new String[4];
for (int k = 0; k < 4; k++) order[k] = PERSONALITY_IDS[(i + k) % 3];
World world = newWorld(seed + i, false, order, true);
totalSystems += world.systems.size();
while (world.victory == null) runTurn(world);
for (Empire empire : world.empires) played[empire.personality.index]++;
Empire winner = world.empires.get(world.victory.empire);
wins[winner.personality.index]++;
winTurns[winner.personality.index] += world.victory.turn;
for (int k = 0; k < VICTORY_KINDS.length; k++) {
if (VICTORY_KINDS[k].equals(world.victory.kind)) {
kinds[k]++;
break;
}
}
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");
for (Personality personality : PERSONALITIES.values()) {
int i = personality.index;
int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i];
io.write(pad(cap(nm(personality)), 20) + padLeft(String.valueOf(played[i]), 10) + padLeft(String.valueOf(wins[i]), 10) + padLeft(percent(wins[i], played[i]) + "%", 8) + padLeft(String.valueOf(avg), 12) + "\n");
}
List<String> parts = new ArrayList<>();
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) {
int[] 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] += BASE[p] + rng.nextInt(SPREAD[p]);
}
for (int k = 0; k < seats.length; k++) {
if (produced[k] >= GOAL) return new int[] {seats[k], turn};
}
}
}
int games = 200;
Random rng = new Random(7);
int[] played = new int[3];
int[] wins = new int[3];
int[] winTurns = new int[3];
int totalTurns = 0;
for (int i = 0; i < games; i++) {
int[] 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"};
System.out.println("Simulación de equilibrio: " + games + " partidas, semilla 7, meta " + GOAL + ".");
System.out.println();
System.out.println(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];
System.out.println(pad(names[i], 20) + padLeft("" + played[i], 10) + padLeft("" + wins[i], 10) + padLeft(share + "%", 8) + padLeft("" + avg, 12));
}
System.out.println();
System.out.println("Turno medio de la victoria: " + totalTurns / games + ".");
Salida
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% 19
Constructor 267 46 17% 20
Erudito 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ácterPartidasVictoriasCuotaTurno medio
Señor de la guerra2236127%47
Constructor2205926%57
Erudito2138037%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:

Terminal
java Cosmos.java

Con --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.

Java 21 ejecuta un archivo con muchas clases directamente, sin javac.

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=1000 con 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.charAt(i)) % 1000000007;
}
return sum;
}
System.out.println(checksum("ab") + ", " + checksum("ba") + ", " + checksum("abc"));
Respuesta

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.

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:

Salida
turn=12 408655515
turn=13 408655516
seed=2026 801283199
COSMOS 993660451

Comprueba 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 difficulty y sube VERSION a 2. Haz que load acepte 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 balance elige 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

1. Un imperio ha investigado la Puerta Estelar y en el mismo turno ha llegado a 9800 producidos. ¿Qué victoria cuenta?
2. Al final del turno 150 dos imperios vivos tienen 219 puntos cada uno. ¿Cuál gana?
3. ¿Por qué la suma de control es sum × 31 + el código del carácter y no la simple suma de los códigos?
4. Alguien cambió a mano un valor del guardado y recalculó él mismo la suma de control. ¿Qué hace la carga?
5. ¿Por qué la simulación rota los caracteres por los asientos ((i + k) % 3) en lugar de sentarlos siempre en el mismo orden?
6. La simulación de Cosmos muestra «puntos 0» de 200 partidas. ¿Qué significa?

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