Ir al contenido
wannacode

El juego entero y el equilibrio

En resumen: El equilibrio se mide con una simulación: miles de combates con semilla, un porcentaje de victorias para cada pareja. Los números se ajustan de uno en uno con un barrido a la misma semilla, y una prueba con partida grabada de todo el juego detecta cualquier cambio de comportamiento.

Problema

¿Es bueno el juego?

El juego está terminado. El mapa se descubre, los combates van por rapidez, las heridas sangran, los enemigos persiguen, una partida guardada se carga. Queda una pregunta que el código no responde: ¿es bueno? ¿Puede la bruja vencer al Guardián del abismo, o el bárbaro pasa por encima de él? Si juegas, pelearás diez veces y tendrás una opinión. Pero diez combates son ruido: en esta lección una misma pregunta («bárbaro contra el Guardián») da 30 % con una semilla y 55 % con otra cuando solo hay 20 combates.

Hace falta un modo de jugar miles de combates en segundos, siempre igual y sin dibujar nada. Eso es una simulación: los combates se ejecutan sin pantalla, IA contra IA, y el resultado es una tabla de porcentajes. Con ella se buscan los números que el juego todavía no tiene: cuánta vida debe tener el Guardián para ser difícil pero vencible.

La lección lo hace dos veces. Primero con un juguete pequeño, para ver cada paso y ejecutarlo en cuatro lenguajes: el combate, la tabla, el ruido, el ajuste y la prueba grabada. Después con el juego real, que ya tiene --simulate. Al final están el juego entero, para jugar y descargar, e ideas para seguir.

La idea

Un combate sin pantalla

Las reglas de un combate en el juego no saben nada de la pantalla: la pantalla solo las llama y dibuja el resultado (la lección sobre la pila de pantallas). Así que las mismas reglas pueden ejecutarse sin pantalla. El juguete de abajo es lo más pequeño que hay: tres héroes contra cuatro enemigos, cada uno con cuatro números (vida, puntería, daño mínimo y máximo). No tiene rapidez, golpes críticos ni efectos: eso está en el sistema de combate, y aquí solo hace falta la forma.

Primero el generador. El juego usa mulberry32 de la primera lección; este es aún más pequeño (de Park y Miller) para que quepa, pero hace lo mismo: la misma semilla, los mismos números.

class Rng {
long state;
Rng(long seed) {
state = seed;
}
int next(int n) {
state = state * 48271 % 2147483647;
return (int) (state % n);
}
}

state * 48271 es mayor que un int, por eso el estado es un long. El resto % n da un número de 0 a n − 1.

El golpe y el combate:

// Un golpe: 0 si falla, si no el daño. La tirada va de 0 a 99.
static int strike(Rng rng, Fighter attacker) {
if (rng.next(100) < attacker.hit()) {
return attacker.min() + rng.next(attacker.max() - attacker.min() + 1);
}
return 0;
}
// El combate: el héroe golpea primero. Termina cuando alguien se queda sin vida.
static Result fight(Rng rng, Fighter hero, Fighter enemy) {
int heroLife = hero.hp();
int enemyLife = enemy.hp();
int turns = 0;
while (true) {
turns++;
enemyLife -= strike(rng, hero);
if (enemyLife <= 0) return new Result(true, turns);
heroLife -= strike(rng, enemy);
if (heroLife <= 0) return new Result(false, turns);
}
}

Un golpe tira un número de 0 a 99. Si es menor que la puntería (hit), es un acierto y el daño es un número aleatorio entre el mínimo y el máximo. Si no, es un fallo. El combate da un turno al héroe y luego al enemigo, hasta que alguien se queda sin vida. Devuelve victoria o derrota y cuántos turnos hizo el héroe.

// Un golpe: 0 si falla, si no el daño. La tirada va de 0 a 99.
static int strike(Rng rng, Fighter attacker) {
    if (rng.next(100)  attacker.hit()) {
        return attacker.min() + rng.next(attacker.max() - attacker.min() + 1);
    }
    return 0;
}

La idea

La tabla de equilibrio

Un combate no dice nada. Por eso los combates se ejecutan muchas veces con un generador común: se suman las victorias y los turnos del héroe.

static int percent(int wins, int fights) {
return wins * 100 / fights;
}
static String cell(int wins, int fights, int turns) {
int tenths = turns * 10 / fights;
return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
// Muchos combates con un generador común: las victorias y los turnos del héroe.
static Tally duels(Rng rng, Fighter hero, Fighter enemy, int fights) {
int wins = 0;
int turns = 0;
for (int i = 0; i < fights; i++) {
Result result = fight(rng, hero, enemy);
if (result.win()) wins++;
turns += result.turns();
}
return new Tally(wins, turns);
}

El porcentaje es una división entera (victorias × 100 ÷ combates, hacia abajo) y los turnos medios van en décimas: turns * 10 / fights, luego X.Y. Así no hay fracciones y la salida es igual en los cuatro lenguajes. La tabla es simulate: las filas son los enemigos, las columnas los héroes.

// La tabla: las filas son los enemigos, las columnas los héroes.
static void simulate(Fighter[] heroes, Fighter[] enemies, int fights, long seed) {
Rng rng = new Rng(seed);
String header = String.format("%-10s", "Enemigo");
for (Fighter hero : heroes) header += String.format("%12s", hero.name());
System.out.println(header);
int[] totals = new int[heroes.length];
for (Fighter enemy : enemies) {
String line = String.format("%-10s", enemy.name());
for (int h = 0; h < heroes.length; h++) {
Tally tally = duels(rng, heroes[h], enemy, fights);
totals[h] += percent(tally.wins(), fights);
line += String.format("%12s", cell(tally.wins(), fights, tally.turns()));
}
System.out.println(line);
}
String average = String.format("%-10s", "Media");
for (int total : totals) average += String.format("%12s", total / enemies.length + "%");
System.out.println(average);
}
Fighter[] heroes = {
new Fighter("Bárbaro", 40, 70, 4, 9),
new Fighter("Amazona", 40, 80, 3, 7),
new Fighter("Bruja", 24, 85, 3, 8),
};
Fighter[] enemies = {
new Fighter("Rata", 9, 60, 1, 3),
new Fighter("Lobo", 18, 70, 2, 5),
new Fighter("Gólem", 40, 60, 3, 6),
new Fighter("Guardián", 54, 70, 3, 7),
};
simulate(heroes, enemies, 200, 7);
Salida
Enemigo Bárbaro Amazona Bruja
Rata 100% 2.7 100% 2.7 100% 2.5
Lobo 100% 4.6 100% 5.0 98% 4.4
Gólem 98% 9.5 93% 10.6 64% 8.0
Guardián 54% 11.0 35% 11.3 7% 7.2
Media 88% 82% 67%

La rata y el lobo caen casi siempre, el gólem ya hace daño a la bruja. El Guardián es el jefe: el bárbaro lo vence en 54 %, la amazona en 35 %, la bruja en 7 %. Si queremos que el Guardián sea difícil pero vencible para cada clase, una heroína está fuera de la banda.

Míralo

Cuántos combates bastan y cómo se ajusta un número

La escena muestra dos preguntas. Arriba: ¿cuántos combates bastan para que el porcentaje se calme? Abajo: ¿cómo se mete a la bruja en la banda cambiando un número?

// Un golpe: 0 si falla, si no el daño. La tirada va de 0 a 99.
static int strike(Rng rng, Fighter attacker) {
if (rng.next(100) < attacker.hit()) {
return attacker.min() + rng.next(attacker.max() - attacker.min() + 1);
}
return 0;
}
// El combate: el héroe golpea primero. Termina cuando alguien se queda sin vida.
static Result fight(Rng rng, Fighter hero, Fighter enemy) {
int heroLife = hero.hp();
int enemyLife = enemy.hp();
int turns = 0;
while (true) {
turns++;
enemyLife -= strike(rng, hero);
if (enemyLife <= 0) return new Result(true, turns);
heroLife -= strike(rng, enemy);
if (heroLife <= 0) return new Result(false, turns);
}
}
static int percent(int wins, int fights) {
return wins * 100 / fights;
}
static String cell(int wins, int fights, int turns) {
int tenths = turns * 10 / fights;
return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
// Muchos combates con un generador común: las victorias y los turnos del héroe.
static Tally duels(Rng rng, Fighter hero, Fighter enemy, int fights) {
int wins = 0;
int turns = 0;
for (int i = 0; i < fights; i++) {
Result result = fight(rng, hero, enemy);
if (result.win()) wins++;
turns += result.turns();
}
return new Tally(wins, turns);
}
// El porcentaje de victorias con un generador nuevo de la semilla dada.
static int winRate(Fighter hero, Fighter enemy, int fights, long seed) {
Tally tally = duels(new Rng(seed), hero, enemy, fights);
return percent(tally.wins(), fights);
}
// La banda del equilibrio: al Guardián se le vence en 30 a 60 de cada 100 combates.
static boolean inBand(int rate) {
return rate >= 30 && rate <= 60;
}
// La vida de la bruja de 24 a 40: cada fila usa la misma semilla, solo cambia la vida.
static void sweep(Fighter warden, int fights, long seed) {
for (int life = 24; life <= 40; life += 4) {
Fighter witch = new Fighter("Bruja", life, 85, 3, 8);
int rate = winRate(witch, warden, fights, seed);
System.out.println(witch.name() + ", vida " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : ""));
}
}
Paso a paso, en palabras
  1. Un combate no dice nada. El bárbaro contra el Guardián gana una vez y pierde otra: tras el primer combate el porcentaje es 0 %, tras el segundo 50 %. Cada punto es el porcentaje de victorias tras tantos combates (la escala es logarítmica).
  2. El bucle suma las victorias: 60 % tras 5 combates, 37 % tras 8, 41 % tras 12. Los puntos aún saltan, porque cada combate es una tirada de dados.
  3. El porcentaje es victorias × 100 ÷ combates, con división entera. Tras 50 combates es 40 %, tras 200 48 %: las oscilaciones se hacen menores.
  4. Tras 1000 combates es 48 % y la línea verde muestra dónde se estableció. La misma pregunta con cinco semillas distintas: con 20 combates las respuestas van de 30 % a 55 %, con 1000 de 48 % a 51 %.
  5. La tabla de 200 combates contra el Guardián: bárbaro 54 %, amazona 35 %, bruja 7 %. La banda va de 30 a 60: dos están dentro, la bruja muy por debajo.
  6. Ajuste: solo cambia un número, la vida de la bruja, y cada fila usa la misma semilla. Con 24 de vida es 5 % (la tabla daba 7 %: otro generador, otra suerte).
  7. 28 de vida: 13 %, 32: 21 %. Su barra crece con la vida, pero aún está por debajo de 30.
  8. 36 de vida: 36 %, la barra se vuelve verde: está dentro. El cambio más pequeño que sirve.
  9. 40 de vida: 51 %: también está dentro, pero ahora la bruja vence al Guardián más a menudo que la amazona (35 %). El barrido muestra toda la curva; la elección es tuya.
  10. Se comprueba la elección, 36 de vida, con 1000 combates: 38 %. Sigue dentro, y el número es más estable que el de 200 combates.

El código junto a la escena es el combate, el bucle de combates y el barrido de los programas de la lección. En cada paso se iluminan las líneas que trabajan en ese momento.

La idea

El ruido: la misma pregunta, otra semilla

Un porcentaje de una simulación es una estimación, no la verdad. Cada semilla da otra serie de tiradas y con ella otro número. ¿Cuánto se separan las respuestas? Aquí está una misma pregunta (bárbaro contra el Guardián) con cinco semillas y dos tamaños:

// El porcentaje de victorias con un generador nuevo de la semilla dada.
static int winRate(Fighter hero, Fighter enemy, int fights, long seed) {
Tally tally = duels(new Rng(seed), hero, enemy, fights);
return percent(tally.wins(), fights);
}
Fighter barbarian = new Fighter("Bárbaro", 40, 70, 4, 9);
Fighter warden = new Fighter("Guardián", 54, 70, 3, 7);
System.out.println(barbarian.name() + " contra " + warden.name() + ":");
for (int fights : new int[] { 20, 1000 }) {
String line = fights + " combates:";
for (long seed = 1; seed <= 5; seed++) {
line += " " + winRate(barbarian, warden, fights, seed) + "%";
}
System.out.println(line);
}
Salida
Bárbaro contra Guardián:
20 combates: 50% 45% 55% 50% 30%
1000 combates: 51% 51% 48% 50% 48%

Con 5 semillas y 20 combates las respuestas van de 30 % a 55 %: una dispersión de 25 puntos para una misma pregunta. Con 1000 combates van de 48 % a 51 %, una dispersión de 3. La regla: el ruido se reduce con la raíz del número de combates. Para reducir el error a la mitad hacen falta cuatro veces más combates.

No hay que confundir dos cosas distintas. Repetibilidad: la misma semilla da exactamente el mismo número, así que una prueba y un compañero ven lo mismo. Ruido: dos semillas distintas dan números distintos, y la cuestión es a cuál creer. La repetibilidad es una propiedad del código, el ruido es una propiedad de la pregunta.

El juego real no es distinto. Una ejecución con 200 combates (como en la salida grabada de la comprobación del sitio) da para el Guardián 41 %, 58 % y 55 %, y con 1000 combates 44 %, 58 % y 54 %. El bárbaro se mueve 3 puntos sin que se haya cambiado nada en el juego.

La idea

Ajuste: un número cada vez

Escribe la meta antes de tocar ningún número: el Guardián es vencido en 30 a 60 de cada 100 combates. Esa banda es la medida, no una opinión del momento. La bruja está por debajo. ¿Qué cambiar? Hay muchas opciones (más vida, un hechizo más fuerte, un Guardián más débil), pero la regla es una: cambia un número y compara con la misma semilla. Si no, no sabes qué cambio hizo el trabajo.

El barrido ejecuta a la bruja con 24, 28, 32, 36 y 40 de vida. Cada fila usa la misma semilla, así que las filas se diferencian solo en la vida:

// La banda del equilibrio: al Guardián se le vence en 30 a 60 de cada 100 combates.
static boolean inBand(int rate) {
return rate >= 30 && rate <= 60;
}
// La vida de la bruja de 24 a 40: cada fila usa la misma semilla, solo cambia la vida.
static void sweep(Fighter warden, int fights, long seed) {
for (int life = 24; life <= 40; life += 4) {
Fighter witch = new Fighter("Bruja", life, 85, 3, 8);
int rate = winRate(witch, warden, fights, seed);
System.out.println(witch.name() + ", vida " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : ""));
}
}
Fighter warden = new Fighter("Guardián", 54, 70, 3, 7);
sweep(warden, 200, 7);
Fighter tuned = new Fighter("Bruja", 36, 85, 3, 8);
int tunedRate = winRate(tuned, warden, 1000, 7);
System.out.println(tuned.name() + ", vida 36, 1000 combates: " + tunedRate + "%" + (inBand(tunedRate) ? " ✓" : ""));
Salida
Bruja, vida 24: 5%
Bruja, vida 28: 13%
Bruja, vida 32: 21%
Bruja, vida 36: 36% ✓
Bruja, vida 40: 51% ✓
Bruja, vida 36, 1000 combates: 38% ✓

La curva crece con la vida. Con 36 de vida la bruja está en 36 % y entra en la banda; con 40 está en 51 %, que ya es más que la amazona (35 %). Se elige el cambio más pequeño que sirve: 36. La última fila comprueba la elección con 1000 combates: 38 %, todavía dentro. La primera fila (24 de vida) da 5 %, y la tabla daba 7 % para la misma heroína: otro flujo de números, otra suerte, no otro juego.

Tras el ajuste se vuelve a grabar la partida del juego (la carta siguiente), para ver que ha cambiado solo lo que querías cambiar.

La idea

Lo mismo en el juego real

El juguete tiene cuatro números por combatiente. El juego real tiene cientos, pero la forma es la misma y ya viene hecha. La región simulate es lo mismo, pero sobre el juego real:

// Equilibrio: cientos de combates sin pantalla, IA contra IA. El nivel del héroe
// coincide con la profundidad del enemigo; la tabla muestra el porcentaje de victorias y el número medio de turnos.
static final int[] SIM_LEVELS = {1, 1, 3, 5, 7};
// El héroe simulado aprende el paso más bajo del árbol (en caso de empate, la primera rama).
static String simSkillChoice(Hero hero) {
String best = "";
for (String skillId : learnableSkills(hero)) {
if (best.equals("") || SKILLS.get(skillId).tier < SKILLS.get(best).tier) best = skillId;
}
return best;
}
static Hero makeSimHero(Game game, HeroClass heroClass, int level) {
Hero hero = createHero(game, cap(nm(heroClass)), heroClass.id);
for (int next = 2; next <= level; next++) {
raiseLevel(hero);
String skill = simSkillChoice(hero);
if (next % 2 == 0 && !skill.equals("")) hero.skills.add(skill);
else if (heroClass.id.equals("barbarian")) applyLevelChoice(hero, new LevelChoice("stat", "strength", 1));
else if (heroClass.id.equals("amazon")) applyLevelChoice(hero, new LevelChoice("stat", "agility", 1));
else applyLevelChoice(hero, new LevelChoice("stat", "maxHp", 8));
}
if (level >= 4) {
hero.equipment.put("weapon", heroClass.betterWeapon);
hero.equipment.put("armor", heroClass.betterArmor);
}
hero.hp = hero.maxHp();
hero.mana = hero.maxMana();
hero.inventory.add("potion_heal", level / 2);
return hero;
}
// El resultado de un combate simulado: si es una victoria y cuántos turnos jugó el héroe.
static class SimResult {
boolean win;
int turns;
SimResult(boolean win, int turns) {
this.win = win;
this.turns = turns;
}
}
static SimResult simulateFight(Game game, HeroClass heroClass, EnemyKind kind) {
game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]);
Battle battle = new Battle(game, new ArrayList<>(List.of(new Enemy(game.newId(), kind))), false);
battle.autoHero = true;
battle.advance();
return new SimResult(battle.outcome.equals("win"), battle.heroTurns);
}
static void simulate(Io io, int fights, int seed) {
Game game = new Game(io, seed);
game.simulation = true;
io.write(t("sim.title", "fights", fights, "seed", Integer.toUnsignedString(seed)) + "\n");
io.write(t("sim.levels") + "\n\n");
String header = pad(t("sim.enemy"), 26) + padLeft(t("sim.level"), 5);
for (HeroClass heroClass : CLASSES.values()) header += padLeft(cap(nm(heroClass)), 14);
io.write(header + "\n");
List<Integer> totals = new ArrayList<>();
for (int i = 0; i < CLASSES.size(); i++) totals.add(0);
int rows = 0;
for (EnemyKind kind : ENEMY_KINDS.values()) {
if (kind.depth == 0) continue;
rows++;
String line = pad(cap(nm(kind)), 26) + padLeft(String.valueOf(SIM_LEVELS[kind.depth]), 5);
int column = 0;
for (HeroClass heroClass : CLASSES.values()) {
int wins = 0;
int turns = 0;
for (int fight = 0; fight < fights; fight++) {
SimResult result = simulateFight(game, heroClass, kind);
if (result.win) wins++;
turns += result.turns;
}
int winShare = percent(wins, fights);
totals.set(column, totals.get(column) + winShare);
column++;
int tenths = (turns * 10) / fights;
line += padLeft(winShare + "% " + tenths / 10 + "." + tenths % 10, 14);
}
io.write(line + "\n");
}
String footer = pad(t("sim.average"), 31);
for (int total : totals) footer += padLeft(total / rows + "%", 14);
io.write(footer + "\n\n" + t("sim.note") + "\n");
}

Las diferencias con el juguete son de realismo, no de idea. El héroe tiene un nivel según la profundidad del enemigo (SIM_LEVELS: 1, 3, 5, 7), aprende habilidades en un orden sencillo (simSkillChoice), lleva mejor equipo desde el nivel 4 y bebe pociones; el combate es el Battle real, lanzado con autoHero, es decir, el héroe en piloto automático elige según la puntuación de utilidad de la lección sobre la IA. Todo lo demás es el juego: no se copió nada para la simulación.

Se lanza con un indicador: node arcanum.js --simulate=1000 --seed=7 --plain (igual en los otros lenguajes). Esta es la salida que obtuve para 1000 combates por pareja:

Salida
Enemigo Nivel Bárbaro Amazona Bruja
Rata gigante 1 100% 2.4 100% 1.3 100% 1.2
Goblin lancero 1 100% 2.4 100% 2.5 100% 2.0
Goblin chamán 1 100% 2.4 100% 2.3 100% 2.6
Lobo sangriento 1 99% 4.0 99% 3.5 92% 3.2
Lobo jefe 3 78% 10.0 93% 7.2 77% 5.8
Esqueleto 3 99% 11.3 100% 4.4 100% 3.0
Cultista 3 99% 5.9 100% 3.5 100% 3.3
Gólem de mena 5 100% 7.6 97% 14.2 99% 7.6
Guardián abisal 7 44% 21.0 58% 19.4 54% 12.3
Media 91% 94% 91%

La fila del Guardián es 44 %, 58 % y 54 %: dentro de la banda de 40 a 60 que fijamos para el juego real. Así está ajustado: el jefe es difícil pero vencible para cada clase.

¿Qué pasa si empiezas a tocar? Los enemigos son datos, no código (la lección sobre objetos y datos de tablas). Esta es la fila del Guardián:

# id|en|de|es|glyph|color|life|strength|agility|defense|speed|min|max|kind|xp|gold|loot|resist|weak|immune|skill|mood|depth
warden|Warden of the Depths|Wächter der Tiefe|Guardián abisal|W|red|120|5|4|3|10|5|9|blunt|150|50|warden|-|-|bleed,poison|warden|guard|4

Su vida es el número 120. Si lo cambias a 100 y ejecutas la misma simulación, el Guardián es vencido en 60 %, 70 % y 72 % en vez de 44 %, 58 % y 54 %. Veinte de vida menos hacen al jefe fácil para todos. La comprobación del sitio ejecuta también esta variante en JavaScript, así que los números de aquí se ejecutaron, no se inventaron.

Más precisamente, para curiosos

La simulación forma parte del propio juego, porque necesita acceso al interior: Battle y Enemy. No es un script externo que lee la pantalla. Por eso la comprobación principal del sitio (la carta siguiente) también la ejecuta en cuatro lenguajes con la semilla 7 y 200 combates y la compara con un archivo.

Otro detalle es deliberadamente simple: el héroe simulado toma el escalón más bajo del árbol de habilidades. Un jugador real elegirá con más inteligencia, así que la tabla dice lo difícil que es el Guardián para un bot sencillo, no para el mejor jugador.

La idea

Una prueba con partida grabada

¿Cómo se sabe que cambiar un número no ha roto otra cosa? El juego tiene una prueba con partida grabada (golden test, snapshot test). El archivo playthrough.txt tiene 230 comandos, uno por línea, desde crear el héroe hasta vencer al Guardián. Los cuatro primeros:

playthrough.txt
1
Iva
1
(Enter)

Se dan al juego como entrada: --plain --seed=2026 --memory-saves (sin colores, con semilla fija, con las partidas guardadas en memoria en vez de en archivos). El juego imprime cada fotograma y la salida tiene 4396 líneas. Aquí están los fotogramas tras los dos primeros comandos, con > comando delante:

> 1
Un nuevo héroe
¿Cómo se llama tu héroe?
Hasta 12 caracteres: letras, cifras, espacio, guion.
> Iva
Un nuevo héroe
Iva, elige una clase:
1) Bárbaro: vida 48, maná 4, fuerza 7, destreza 4
Un hacha cuyas heridas sangran. Mucha vida. Habilidad:
Ira (fuerza a cambio de defensa).
2) Amazona: vida 34, maná 10, fuerza 3, destreza 8
Lanza y ojo de cazadora: golpes certeros, a menudo
críticos. Habilidad: Disparo doble.
3) Bruja: vida 26, maná 20, fuerza 1, destreza 5
Poca vida, mucho maná. Bola de fuego, Nube venenosa y
Curar.

La salida está guardada en un archivo, playthrough.es.out. La comprobación del sitio ejecuta el juego en cada uno de los cuatro lenguajes con cada uno de los cuatro idiomas de los textos y compara byte a byte con el archivo: 20 comparaciones junto con la simulación. Un cambio en las reglas, en el orden de las tiradas o en un texto que se imprime cambia la salida, y la comprobación falla.

Aquí hay un modelo pequeño de lo mismo. Se graba un combate del bárbaro contra el lobo (semilla 2027); luego se cambia un número (el daño máximo del lobo, de 5 a 6) y se compara el resultado con la grabación:

// Una línea del registro del combate.
static String describe(int turn, String who, String target, int damage, int left) {
if (damage == 0) return turn + ". " + who + " falla";
return turn + ". " + who + " golpea " + damage + " → " + target + " " + left;
}
static Result fight(Rng rng, Fighter hero, Fighter enemy, List<String> log) {
int heroLife = hero.hp();
int enemyLife = enemy.hp();
int turns = 0;
while (true) {
turns++;
int damage = strike(rng, hero);
enemyLife -= damage;
log.add(describe(turns, hero.name(), enemy.name(), damage, Math.max(0, enemyLife)));
if (enemyLife <= 0) return new Result(true, turns);
damage = strike(rng, enemy);
heroLife -= damage;
log.add(describe(turns, enemy.name(), hero.name(), damage, Math.max(0, heroLife)));
if (heroLife <= 0) return new Result(false, turns);
}
}
// La primera línea en la que los dos registros se diferencian (0 si son iguales).
static int firstDifference(List<String> a, List<String> b) {
for (int i = 0; i < Math.max(a.size(), b.size()); i++) {
if (i >= a.size() || i >= b.size() || !a.get(i).equals(b.get(i))) return i + 1;
}
return 0;
}
static String lineAt(List<String> lines, int at) {
return at <= lines.size() ? lines.get(at - 1) : "-";
}
// La prueba: un combate con la semilla 2027 comparado con la grabación.
static void check(String label, List<String> recorded, Fighter hero, Fighter wolf) {
List<String> log = new ArrayList<>();
fight(new Rng(2027), hero, wolf, log);
int at = firstDifference(recorded, log);
if (at == 0) {
System.out.println(label + ": bien, " + log.size() + " líneas");
} else {
System.out.println(label + ": línea " + at);
System.out.println(" esperado: " + lineAt(recorded, at));
System.out.println(" obtenido: " + lineAt(log, at));
}
}
List<String> recorded = List.of(
"1. Bárbaro golpea 6 → Lobo 12",
"1. Lobo falla",
"2. Bárbaro falla",
"2. Lobo falla",
"3. Bárbaro falla",
"3. Lobo golpea 3 → Bárbaro 37",
"4. Bárbaro golpea 4 → Lobo 8",
"4. Lobo golpea 2 → Bárbaro 35",
"5. Bárbaro golpea 5 → Lobo 3",
"5. Lobo falla",
"6. Bárbaro golpea 5 → Lobo 0"
);
Fighter hero = new Fighter("Bárbaro", 40, 70, 4, 9);
check("lobo, daño máximo 5", recorded, hero, new Fighter("Lobo", 18, 70, 2, 5));
check("lobo, daño máximo 6", recorded, hero, new Fighter("Lobo", 18, 70, 2, 6));
Salida
lobo, daño máximo 5: bien, 11 líneas
lobo, daño máximo 6: línea 6
esperado: 3. Lobo golpea 3 → Bárbaro 37
obtenido: 3. Lobo golpea 5 → Bárbaro 35

El programa no dice «hay una diferencia», sino cuál es la primera línea que difiere: la línea 6 de 11. Hasta esa línea el comportamiento es igual, así que la diferencia está donde se cambió y se lee en segundos. Las primeras 5 líneas son iguales porque el lobo tira los mismos números: solo cambió el rango del que se toma el daño.

Cuando la prueba falla hay dos casos:

  • El cambio era querido (ajustaste un número). Lees la diferencia, ves que es solo lo que esperabas y grabas la partida de nuevo.
  • El cambio era inesperado. Es un error que, si no, habría llegado a un jugador.

Nunca se vuelve a grabar sin leer la diferencia: entonces la prueba protege todo, incluso el error. Y al revés: una prueba en verde no significa que el juego sea correcto ni divertido, solo que es igual que cuando se grabó.

La idea

El juego entero

Esto es lo que construimos en las lecciones y dónde está en el juego. Las regiones son de la versión de JavaScript, las líneas son de nivel superior:

LecciónRegionesLíneas
game-loopmain game353
screen-stackscreen screen-stack92
screen-resultstarget-screen overlays465
console-renderbuffer terminal hud168
map-fovlevel fov map-load map-screen500
entitiesentity234
combatcombat-order attack battle-screen381
wounds-effectseffects bleeding198
actionsactions replay replay-screen234
aipathfind ai-map ai-battle216
items-lootitems-data inventory equip loot shop267
quests-eventsxp-levelup event-bus quests dialog dialogs-data skills-data296
save-loadsave load311
arcanum-finalsimulate node137

Todo son unas 5500 líneas en cada lenguaje; el resto son datos, textos, su carga y las pantallas del título, el pueblo y el héroe. La tabla no es un reparto completo, sino una guía: dónde mirar cuando quieras cambiar algo.

Juégalo hasta el final (es el mismo juego que en la primera lección) y descárgalo:

Las crónicas de Arcanum

La mina bajo Vadogris lleva tres días en silencio. Hay sangre en el camino.

Haz clic en la pantalla para jugar con las teclas (flechas o WASD, I, M, dígitos); Enter pasa a la línea de comandos. Las partidas guardadas se quedan en este navegador.

Los archivos son: [Arcanum.java](/games/arcanum/es/Arcanum.java), [Arcanum.cs](/games/arcanum/es/Arcanum.cs), [arcanum.py](/games/arcanum/es/arcanum.py), [arcanum.js](/games/arcanum/es/arcanum.js). Pon uno en una carpeta vacía y ejecútalo:

Terminal
java Arcanum.java

Con --simulate=1000 imprime la tabla de equilibrio en vez de jugar, con --seed=42 el mundo es siempre el mismo, con --lang=en el juego está en inglés (también hay bg, de y es), con --plain no hay colores. Las partidas guardadas van a una carpeta saves junto al juego.

La idea

Cómo seguir

El juego está hecho para que se pueda añadir casi todo sin tocar el bucle. Algunas ideas, de la más pequeña a la más grande, con el sitio del código por donde empezar:

  1. Un enemigo nuevo. Una fila en la tabla de enemigos (enemies-data), un signo y un color, un lugar en el mapa (maps-data). Después la simulación te dice si es demasiado fácil. No se toca nada del código.
  2. Una clase nueva. Una fila en CLASSES_DATA, habilidades en skills-data y textos en cuatro idiomas. La simulación muestra la columna de la clase nueva junto a las otras tres. Ajústala con el barrido de esta lección hasta que entre en la banda.
  3. Una misión nueva. Un diálogo en dialogs-data y una suscripción a los eventos (quests): el pequeño encargo «trae mineral» es el ejemplo más corto del juego.
  4. Un efecto nuevo. Congelar o ralentizar en effects: elige la regla de acumulación (si se alarga, si se suma) como se eligió para el sangrado y el veneno.
  5. Un enemigo más listo. Sustituye la búsqueda en anchura por A* o por un campo de distancias (mira «Con más precisión» en la lección sobre la IA) o da a los lobos una táctica de manada.
  6. Otra vista. Terminal es una interfaz con dos tipos; un tercero puede dibujar en un canvas o en una ventana sin que las pantallas lo noten. La entrada y la salida están separadas (io) justo para eso.
  7. Un idioma. Los textos son tablas por idioma: añades un quinto idioma con una copia más de la tabla de traducciones; si falta una clave, el juego se detiene con missing text en vez de saltarla en silencio.
  8. Un port nuevo. El juego existe en cuatro lenguajes con una sola salida. Un quinto (Rust, Go, Kotlin) se comprueba contra el mismo playthrough.out: mientras no sea byte a byte igual, el port no está terminado.
  9. Modo diario. Una semilla sacada de la fecha, y todos juegan el mismo mundo; la repetición de un combate (la lección sobre los comandos) ya graba los movimientos.

Hagas lo que hagas, ejecuta primero la prueba con partida grabada y cambia después. Si falla, lee la primera diferencia. Esa es toda la lección sobre el equilibrio y sobre el mantenimiento de un juego en una.

Este es el final del módulo. Empezamos con la pregunta «cómo está hecho un juego» y llegamos a un juego entero en cuatro lenguajes con sangre, combates por rapidez, enemigos que piensan, una partida guardada que se rechaza si está rota y una herramienta que dice si es bueno. Lo demás depende de ti.

Pruébalo

Predice la salida

La función cell escribe una celda de la tabla: el porcentaje de victorias y los turnos medios. ¿Qué imprime el programa?

static int percent(int wins, int fights) {
return wins * 100 / fights;
}
static String cell(int wins, int fights, int turns) {
int tenths = turns * 10 / fights;
return percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
System.out.println(cell(1, 3, 7));
System.out.println(cell(2, 3, 10));
System.out.println(cell(3, 3, 9));
Respuesta

Pruébalo

Encuentra el error

Un compañero quiere dejar la simulación «más limpia» y da a cada combate un generador nuevo con la misma semilla. La tabla ya no se parece a nada:

Fighter[] heroes = {
new Fighter("Bárbaro", 40, 70, 4, 9),
new Fighter("Amazona", 40, 80, 3, 7),
new Fighter("Bruja", 24, 85, 3, 8),
};
Fighter golem = new Fighter("Gólem", 40, 60, 3, 6);
for (Fighter hero : heroes) {
Tally tally = duelsBug(7, hero, golem, 200);
System.out.println(hero.name() + ": " + cell(tally.wins(), 200, tally.turns()));
}
Salida
Bárbaro: 100% 10.0
Amazona: 100% 11.0
Bruja: 0% 8.0

Contra el gólem los héroes ganan 100 %, 100 % y 0 %, y los turnos son números enteros. ¿Qué línea tiene la culpa?

Elige la línea con el error.

Más precisamente

Con más precisión: ruido, bots y grabaciones

Cuánto es el error. Para un porcentaje p de n combates el error estándar es √(p(1 − p) / n). Con p = 50 % son 11.2 puntos con 20 combates, 3.5 con 200 y 1.6 con 1000. El doble del error estándar (una confianza de alrededor del 95 %) hace el intervalo de ±7 puntos con 200 combates. Por eso una diferencia de 3 puntos entre dos versiones de un ajuste no significa nada con 200 combates, y una diferencia de 20 significa mucho. Una regla: ejecuta más combates donde la decisión está en el límite.

No ajustes a una sola semilla. Si eliges el número hasta que quede bien con la semilla 7, estás ajustando a la suerte de la semilla 7. Comprueba con varias semillas (digamos tres) y toma un promedio razonable.

Un bot no es un jugador. El héroe simulado toma las decisiones más simples. La tabla dice cómo es el equilibrio para un bot así; un jugador real es más listo (elige habilidades, huye a tiempo) o más torpe (no bebe una poción). Por eso una simulación detecta los errores gruesos (una clase inútil, un jefe imbatible), y el ajuste fino se hace con jugadores.

El porcentaje de victorias no es diversión. Además de las victorias se miden los turnos medios (la columna de arriba), la vida que sobra, cuántas pociones se bebieron. Un combate que se gana el 100 % de las veces dejando al héroe 1 de vida no es lo mismo que uno que se gana el 100 % con 40.

Qué rompe la repetibilidad. El orden de iteración de una tabla hash, las fracciones, la fecha actual, la configuración de idioma del sistema, una ordenación inestable sin segunda regla. El juego los evita (colecciones ordenadas, números enteros, una segunda regla al ordenar) y la comprobación en cuatro lenguajes detecta cualquier regreso del problema.

¿Qué commit cambió el comportamiento? La prueba es automática y rápida, así que git bisect puede ejecutarla y encontrar el commit tras el cual la grabación ya no coincide.

Cuántas pruebas hacen falta. La partida grabada es una prueba de integración: detecta todo, pero solo dice «aquí está la primera diferencia», no «aquí está el error». A su lado están las pruebas pequeñas de reglas sueltas (daño, acumulación de efectos). Al construir el juego también ejecutamos 300 partidas con comandos aleatorios: sin caídas y sin ningún número que no fuera un número. Así se hacen las capas.

Ejercicio

El enemigo más difícil

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

El enemigo más difícil. Después de la tabla, imprime para cada héroe el enemigo contra el que el porcentaje de victorias es el más bajo, y ese porcentaje. El orden de los héroes es el de la tabla. Con porcentajes iguales gana el primer enemigo. El generador es el mismo (uno común para todas las parejas, semilla 7, 200 combates) para que los números coincidan con la tabla.

La salida es:

Salida
Enemigo más difícil para Bárbaro: Guardián (54%)
Enemigo más difícil para Amazona: Guardián (35%)
Enemigo más difícil para Bruja: Guardián (7%)

Más tareas

  • Una clase nueva. Añade un cuarto héroe (por ejemplo un cazador con 32 de vida, puntería 90 y daño de 2 a 6) y mira su columna. Ajústalo con un barrido hasta que el Guardián esté en la banda.
  • Confianza. Muestra junto a cada porcentaje el error estándar (√(p(1 − p) / n)), para ver qué diferencias son ruido.
  • Ajuste automático. Escribe una función que suba la vida de la bruja de 1 en 1 hasta que el porcentaje contra el Guardián entre en la banda, y la devuelva. ¿Por qué el porcentaje debe crecer con la vida para que esta búsqueda funcione, y puedes acelerarla con una búsqueda binaria?
  • Argumentos. Haz que --fights= y --seed= se lean de los argumentos del programa, como hace el juego.

Test

Ponte a prueba

1. El bárbaro vence al Guardián en 50 %, 30 % o algo intermedio cuando solo se ejecutan 20 combates con semillas distintas. Con 1000 combates las respuestas van de 48 % a 51 %. ¿Por qué una simulación es mejor prueba que unos pocos combates jugados a mano?
2. El barrido sobre la vida de la bruja empieza cada fila con un generador nuevo de la misma semilla. ¿Por qué?
3. Tras cambiar un número en los datos de un enemigo, la prueba con partida grabada falla. ¿Qué haces?
4. La bruja con 36 de vida gana 36 % con 200 combates y 38 % con 1000 combates. ¿En qué número confías más?
5. La prueba con partida grabada está en verde. ¿Qué demuestra?

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