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 tirada va de 0 a 99, y puntería 70 significa 70 de cada 100. Los números que ganan son del 0 al 69: menores que 70. Con <= serían 71 y cada golpe sería un 1 % más certero de lo escrito. Ningún combate suelto muestra un error así, pero la tabla sí.
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);Enemigo Bárbaro Amazona BrujaRata 100% 2.7 100% 2.7 100% 2.5Lobo 100% 4.6 100% 5.0 98% 4.4Gólem 98% 9.5 93% 10.6 64% 8.0Guardián 54% 11.0 35% 11.3 7% 7.2Media 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
- 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).
- 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.
- El porcentaje es victorias × 100 ÷ combates, con división entera. Tras 50 combates es 40 %, tras 200 48 %: las oscilaciones se hacen menores.
- 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 %.
- 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.
- 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).
- 28 de vida: 13 %, 32: 21 %. Su barra crece con la vida, pero aún está por debajo de 30.
- 36 de vida: 36 %, la barra se vuelve verde: está dentro. El cambio más pequeño que sirve.
- 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.
- 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);}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) ? " ✓" : ""));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:
Enemigo Nivel Bárbaro Amazona BrujaRata gigante 1 100% 2.4 100% 1.3 100% 1.2Goblin lancero 1 100% 2.4 100% 2.5 100% 2.0Goblin chamán 1 100% 2.4 100% 2.3 100% 2.6Lobo sangriento 1 99% 4.0 99% 3.5 92% 3.2Lobo jefe 3 78% 10.0 93% 7.2 77% 5.8Esqueleto 3 99% 11.3 100% 4.4 100% 3.0Cultista 3 99% 5.9 100% 3.5 100% 3.3Gólem de mena 5 100% 7.6 97% 14.2 99% 7.6Guardián abisal 7 44% 21.0 58% 19.4 54% 12.3Media 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|depthwarden|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|4Su 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:
1Iva1(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:
> 1Un nuevo héroe
¿Cómo se llama tu héroe?Hasta 12 caracteres: letras, cifras, espacio, guion.> IvaUn 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));lobo, daño máximo 5: bien, 11 líneaslobo, daño máximo 6: línea 6 esperado: 3. Lobo golpea 3 → Bárbaro 37 obtenido: 3. Lobo golpea 5 → Bárbaro 35El 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ón | Regiones | Líneas |
|---|---|---|
| game-loop | main game | 353 |
| screen-stack | screen screen-stack | 92 |
| screen-results | target-screen overlays | 465 |
| console-render | buffer terminal hud | 168 |
| map-fov | level fov map-load map-screen | 500 |
| entities | entity | 234 |
| combat | combat-order attack battle-screen | 381 |
| wounds-effects | effects bleeding | 198 |
| actions | actions replay replay-screen | 234 |
| ai | pathfind ai-map ai-battle | 216 |
| items-loot | items-data inventory equip loot shop | 267 |
| quests-events | xp-levelup event-bus quests dialog dialogs-data skills-data | 296 |
| save-load | save load | 311 |
| arcanum-final | simulate node | 137 |
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:
java Arcanum.javaCon --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:
- 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. - Una clase nueva. Una fila en
CLASSES_DATA, habilidades enskills-datay 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. - Una misión nueva. Un diálogo en
dialogs-datay una suscripción a los eventos (quests): el pequeño encargo «trae mineral» es el ejemplo más corto del juego. - 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. - 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.
- Otra vista.
Terminales una interfaz con dos tipos; un tercero puede dibujar en uncanvaso en una ventana sin que las pantallas lo noten. La entrada y la salida están separadas (io) justo para eso. - 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 texten vez de saltarla en silencio. - 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. - 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));Tanto el porcentaje como los turnos medios son división entera, hacia abajo. 1 victoria de 3 es 33 % (no 33,3 ni 34), 2 de 3 es 66 %, 3 de 3 es 100 %. Los turnos van en décimas: 7 turnos para 3 combates son 70 / 3 = 23 décimas, es decir 2.3; 10 turnos son 3.3; 9 turnos son 90 / 3 = 30, es decir 3.0. La segunda respuesta redondea (67 %), la tercera da los turnos medios como número entero.
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()));}Bárbaro: 100% 10.0Amazona: 100% 11.0Bruja: 0% 8.0Contra 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.
new Rng(seed) dentro del bucle empieza la serie desde el principio en cada combate. Así los 200 combates son un mismo combate repetido 200 veces: el resultado es 0 % o 100 % y los turnos medios son números enteros. El generador debe crearse una sola vez antes del bucle y pasarse a cada combate (rng en duels). La semilla da repetibilidad, pero no debe volver a entregarse en cada tirada.
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:
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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Métodos de instancia · static: lo que comparten todos los objetos · Listas · record, switch y patrones
- Ejercicios sobre el tema: Las crónicas de Arcanum