Skills and actions as commands
In short: A command is an object that knows how to execute itself and how to record itself as data. The battle executes commands through one method; who chooses them (a player, an artificial intelligence or a record) is a separate question. That is why recorded commands and the same seed repeat a battle, and without a screen the battle runs thousands of times.
Problem
Four choose, one executes
In the game’s battle there are four “choosers”. The player presses 1, 2, 3 or 4. The goblin simply strikes. The hero on autopilot, which drives the simulation, works out what is best. And the battle replay has to do what happened, with nobody choosing anything. There are four kinds of choices, and the battle that executes them is one.
If the choice is a chain if (choice == 1) … else if (choice == 2) … and it lives in four places, every new action is four changes and four chances to forget one. Add “drink a potion” and the replay will quietly not know it, and will show another battle.
Here is a battle in the game. After 2 a list of skills opens, and those that are still recharging are marked (Rage: in 4 turns):
=== Battle ===Iva, barbarian lv6 L [##########--] 86/103 M [########] 9/9 ra+- Skills -----------------------------------------+1) W| 1) Rage (0 mana) (in 4) | Bat| 2) Whirlwind (3 mana) | Iva| 3) War cry (2 mana) | Iva| 4) Iron skin (3 mana) | War| | War| number: choose, x: back |1 at+--------------------------------------------------+The idea is to turn the action into an object: a command. Every command knows how to execute itself and how to record itself as data. The battle does not ask what it is, it only executes it. Who chose it (a player, an enemy, an artificial intelligence or a record) is a different question, and the battle does not deal with it. The lesson builds this on a small arena and then shows the same place in the game: the commands, skills from a table, mana and cooldowns, targets, the battle replay and the simulation without a screen.
The idea
A command is an object
The arena of the lesson is small: a warrior against a goblin and a rat. The arithmetic is integers, and the “dice” are a seeded generator (as in the intro lesson), so the battle is the same in every language. There are four kinds of commands, and from the battle’s point of view they all do the same thing:
// The record of one action: who (a number), what, against whom (a number or -1), and which skill or item.record ActionRecord(int actor, String action, int target, String id) {}
// A command is an object: it knows how to record itself and how to execute.interface BattleAction { ActionRecord toRecord(Battle battle);
void execute(Battle battle);}
class AttackAction implements BattleAction { final Fighter actor; final Fighter target;
AttackAction(Fighter actor, Fighter target) { this.actor = actor; this.target = target; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(actor), "attack", battle.indexOf(target), ""); }
public void execute(Battle battle) { battle.hit(actor, target, actor.power + battle.rng.roll(3)); }}
class ItemAction implements BattleAction { final Fighter actor; final String itemId;
ItemAction(Fighter actor, String itemId) { this.actor = actor; this.itemId = itemId; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(actor), "item", -1, itemId); }
public void execute(Battle battle) { actor.potions -= 1; int healed = Math.min(10, actor.maxHp - actor.hp); actor.hp += healed; battle.say(actor.name + ": potion, +" + healed + " life"); }}
class FleeAction implements BattleAction { final Fighter actor;
FleeAction(Fighter actor) { this.actor = actor; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(actor), "flee", -1, ""); }
public void execute(Battle battle) { if (battle.rng.roll(100) < 50) { battle.say(actor.name + " flees."); battle.over = true; battle.outcome = "fled"; } else { battle.say(actor.name + " cannot get away."); } }}Every command keeps who acts (actor) and against whom (target) and has two methods:
execute(battle): carries it out. The command does not work out other people’s life itself: it callsbattle.hit, which lowers the life and writes the line to the log;toRecord(battle): describes it as data: the number of the fighter, a word for the kind, the number of the target and the skill or item. The record holds no life, no damage and no randomness.
That is why the battle can hold a list of commands of different kinds and call the same method on all of them: it is the polymorphism from inheritance, only here the commands are objects with a common interface. The program puts a hero strike, a goblin strike, a skill and a potion in a list and hands them to perform one by one. The lines of the battle come out at once (live), and the record is printed at the end.
Battle battle = newBattle(7);battle.live = true;Fighter hero = battle.hero();Fighter goblin = battle.fighters.get(1);List<BattleAction> queue = List.of( new AttackAction(hero, goblin), new AttackAction(goblin, hero), new SkillAction(hero, skillOf(battle, "smash"), goblin), new ItemAction(hero, "potion"));for (BattleAction action : queue) { battle.perform(action);}System.out.println("Record:");for (ActionRecord r : battle.record) { System.out.println(describe(r));}Warrior → Goblin: 8 damageGoblin → Warrior: 8 damageWarrior: SmashWarrior → Goblin: 12 damageGoblin falls.Warrior: potion, +8 lifeRecord:0 attack 11 attack 00 skill 1 smash0 item -1 potionThe record is what is left of the battle: four lines of a few words. It is needed for the replay, which comes later.
In the game the classes have the same shape. This is how the strike and the escape look:
static class AttackAction implements BattleAction { Creature actor; Creature target;
AttackAction(Creature actor, Creature target) { this.actor = actor; this.target = target; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(this.actor), "attack", battle.indexOf(this.target), ""); }
public void execute(Battle battle) { strike(battle, this.actor, this.target, this.actor.attackProfile(), 100); }}static class FleeAction implements BattleAction { Creature actor;
FleeAction(Creature actor) { this.actor = actor; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(this.actor), "flee", -1, ""); }
public void execute(Battle battle) { if (!battle.canFlee) { battle.say(t("combat.noFlee")); return; } int fastest = 0; for (Enemy enemy : battle.aliveEnemies()) fastest = Math.max(fastest, enemy.stat("agility")); int chance = clamp(50 + (this.actor.stat("agility") - fastest) * 5, 15, 90); if (battle.game.rng.chance(chance)) { battle.say(t("combat.fled", "name", this.actor.name)); battle.over = true; battle.outcome = "fled"; } else { battle.say(t("combat.fleeFail", "name", this.actor.name)); } }}The escape has a chance (by the agility of the fastest enemy) but writes no damage. Enemies use commands too: skills such as the shaman’s spit or its healing are the class SpecialAction, one per kind of skill.
More precisely, if you are curious
This is the Command pattern (from the “Gang of Four” book): a request becomes an object. Things that are hard with if follow from it: recording (save the commands), replay (play them again), undo (every command also knows its opposite action and the commands are rolled back along a stack) and queueing (a command waits for its turn). This lesson uses the first two. Undo in a battle with dice, bleeding and death is hard, because the opposite of “damage 7” is not simply “life + 7”, so the game keeps a record, not an undo stack. In a text editor or a drawing program the pattern is used exactly for undo.
The idea
The battle executes
A battle is a class with fighters, a log and a record. What matters in it is one place that every command passes through:
class Battle { final Rng rng; final List<Fighter> fighters; final List<Skill> skills; final List<String> log = new ArrayList<>(); final List<ActionRecord> record = new ArrayList<>(); boolean over = false; boolean live = false; String outcome = "none"; int rounds = 0;
Battle(int seed, List<Fighter> fighters, List<Skill> skills) { this.rng = new Rng(seed); this.fighters = fighters; this.skills = skills; }
Fighter hero() { return fighters.get(0); }
void say(String text) { log.add(text); if (live) System.out.println(text); }
int indexOf(Fighter fighter) { return fighters.indexOf(fighter); }
List<Fighter> aliveEnemies() { List<Fighter> result = new ArrayList<>(); for (int i = 1; i < fighters.size(); i++) { if (fighters.get(i).alive()) result.add(fighters.get(i)); } return result; }
void hit(Fighter actor, Fighter target, int amount) { target.hp = Math.max(0, target.hp - amount); say(actor.name + " → " + target.name + ": " + amount + " damage"); if (!target.alive()) say(target.name + " falls."); }
// The only place every command passes through: it is recorded first, then executed. void perform(BattleAction action) { record.add(action.toRecord(this)); action.execute(this); checkEnd(); }
void checkEnd() { if (over) return; if (!hero().alive()) { over = true; outcome = "lose"; say("Defeat."); } else if (aliveEnemies().isEmpty()) { over = true; outcome = "win"; say("Victory!"); } }}perform does three things in this order: it records the command, executes it, and checks whether the battle is over. There is no way around it: neither the hero, nor the goblin, nor the replay executes anything by another route. That is why the record is complete: if something changed the battle, it passed through here.
hit is the second place that deserves to be only one: it alone lowers the life and writes the line “who → whom: damage”. That makes the log exact enough to be compared line by line. In the game, the consequences from the previous lesson are applied after every strike too: bleeding, stun, burning, poison. They live in one place, so they are the same for a player, an enemy, a skill and a replay, instead of one copy in every command.
In the game perform is the same, except that after executing it handles deaths (loot, experience, events for the quests):
void perform(BattleAction action) { this.record.add(action.toRecord(this)); action.execute(this); this.handleDeaths();}The idea
Skills are data
SkillAction is one class, and there are many skills: Cleave strikes everyone, Smash strikes one enemy with 170% of the strength. The difference is in a table, not in code. Every line of the text is one skill: an identifier, mana, cooldown, strength in percent, target and a display name:
static final String SKILLS = """ cleave|3|3|80|all|Cleave smash|4|4|170|one|Smash""";
record Skill(String id, int mana, int cooldown, int percent, String target, String name) {}
static List<Skill> loadSkills(String text) { List<Skill> skills = new ArrayList<>(); for (String line : text.split("\n")) { String[] p = line.split("\\|"); skills.add(new Skill(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]), p[4], p[5])); } return skills;}The program reads the text once at start (loadSkills) and keeps Skill records. A new skill of a known kind is a new line, not a new class. This is the same idea as the table of tiles from the map: data is separated from code.
In the game the table is wider: besides mana and cooldown there is a class and a branch in the skill tree, a kind (a weapon strike, a spell, healing, an effect on yourself, an effect on the enemies), a target one, all or self, a number of hits, spell damage, and an effect with turns and strength. Here are its first lines, with the columns that hold the names in the other languages left out:
// Skills: class, branch (1 or 2), step in the branch (1-3), kind (weapon: with the weapon, spell: a spell, // heal: healing, buff: an effect on yourself, debuff: an effect on enemies), target (one, all, self), // mana, cooldown in turns, hits, power as a percent of a weapon hit, spell damage // (min-max), damage type ("-" is the weapon's type), effect, effect turns, effect power. static final String SKILLS_DATA = """# id|class|branch|step|kind|target|mana|cooldown|hits|power|min|max|damage|effect|turns|effect power|namerage|barbarian|1|1|buff|self|0|5|0|0|0|0|-|rage|3|6|Ragewhirlwind|barbarian|1|2|weapon|all|3|3|1|80|0|0|-|-|0|0|Whirlwindbloodlust|barbarian|1|3|weapon|one|4|4|1|170|0|0|slash|bleed|3|2|BloodlustOne SkillAction in the game reads a line of this table and, depending on the kind, makes a weapon strike, casts a spell, heals, or applies an effect:
static class SkillAction implements BattleAction { Hero actor; Skill skill; Creature target;
SkillAction(Hero actor, Skill skill, Creature target) { this.actor = actor; this.skill = skill; this.target = target; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(this.actor), "skill", battle.indexOf(this.target), this.skill.id); }
public void execute(Battle battle) { Hero hero = this.actor; Skill skill = this.skill; hero.mana -= skill.mana; hero.cooldowns.put(skill.id, skill.cooldown); battle.say(t("combat.uses", "name", hero.name, "skill", nm(skill))); List<Creature> targets = new ArrayList<>(); targets.add(this.target); if (skill.target.equals("all")) targets = new ArrayList<>(battle.aliveEnemies()); if (skill.target.equals("self")) targets = new ArrayList<>(List.of(hero)); for (Creature target : targets) { if (skill.kind.equals("weapon")) { AttackProfile profile = hero.attackProfile(); if (!skill.type.equals("-")) profile.type = skill.type; for (int hit = 0; hit < skill.hits; hit++) { if (!target.isAlive()) break; boolean landed = strike(battle, hero, target, profile, skill.power); if (landed && !skill.effect.equals("-")) applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } } else if (skill.kind.equals("spell")) { spellHit(battle, hero, target, skill.min, skill.max, skill.type); if (!skill.effect.equals("-")) applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } else if (skill.kind.equals("heal")) { int healed = hero.heal(skill.min + hero.level * 2); battle.say(t("combat.healed", "name", hero.name, "amount", healed)); if (!skill.effect.equals("-")) applyEffect(hero, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } else { applyEffect(target, makeEffect(skill.effect, skill.effectTurns, skill.effectPower), battle); } } }}The idea
Mana and cooldowns
A skill with no cost is the best choice every time. So there are two limiters: mana (a stock that is spent) and cooldown (for how many turns a skill is unavailable after use). In the code they live in three places and each does one thing:
SkillAction.executepays: it lowers mana and sets the cooldown;Fighter.tickcounts: at the start of a fighter’s turn every cooldown goes down by one;problemasks: it returns the reason why a skill is not available, or an empty string.
// Why a skill is not available now: an empty string if it is.static String problem(Battle battle, Fighter hero, Skill skill) { if (hero.cooldownOf(skill.id()) > 0) return "recharging: " + hero.cooldownOf(skill.id()); if (hero.mana < skill.mana()) return "not enough mana"; return "";}
static List<Skill> usableSkills(Battle battle, Fighter hero) { List<Skill> result = new ArrayList<>(); for (Skill skill : battle.skills) { if (problem(battle, hero, skill).isEmpty()) result.add(skill); } return result;}class Fighter { final String name; int hp; final int maxHp; final int power; int mana; final int maxMana; int potions; final Map<String, Integer> cooldowns = new HashMap<>();
Fighter(String name, int hp, int power, int mana, int potions) { this.name = name; this.hp = hp; this.maxHp = hp; this.power = power; this.mana = mana; this.maxMana = mana; this.potions = potions; }
boolean alive() { return hp > 0; }
int cooldownOf(String skillId) { return cooldowns.getOrDefault(skillId, 0); }
// A turn passes: every cooldown gets shorter by one. void tick() { cooldowns.replaceAll((skillId, left) -> Math.max(0, left - 1)); }}The program gives the hero six turns and shows for every skill whether it is ready, and if not, why. On every turn the hero uses the first available one:
Battle battle = newBattle(7);Fighter hero = battle.hero();for (int turn = 1; turn <= 6; turn++) { hero.tick(); System.out.println("Turn " + turn + ", mana " + hero.mana); Skill chosen = null; for (Skill skill : battle.skills) { String why = problem(battle, hero, skill); System.out.println(" " + skill.name() + ": " + (why.isEmpty() ? "ready" : why)); if (why.isEmpty() && chosen == null) chosen = skill; } if (chosen == null) { System.out.println(" nothing usable"); continue; } System.out.println(" -> uses " + chosen.name()); battle.perform(new SkillAction(hero, chosen, battle.aliveEnemies().get(0)));}Turn 1, mana 8 Cleave: ready Smash: ready -> uses CleaveTurn 2, mana 5 Cleave: recharging: 2 Smash: ready -> uses SmashTurn 3, mana 1 Cleave: recharging: 1 Smash: recharging: 3 nothing usableTurn 4, mana 1 Cleave: not enough mana Smash: recharging: 2 nothing usableTurn 5, mana 1 Cleave: not enough mana Smash: recharging: 1 nothing usableTurn 6, mana 1 Cleave: not enough mana Smash: not enough mana nothing usableLook at turn 2: Cleave is still recharging (2 turns), but Smash is ready, because the mana is enough. On turn 4 Cleave has recharged, but the mana (1) is not enough. On turn 6 both have recharged and the mana is still not enough for either. That is why problem checks the cooldown first: for Smash the reason changes from “recharging” to “not enough mana” only when the cooldown is over.
actor.cooldowns.put(skill.id(), );When a skill is used, SkillAction puts the number from the table into the fighter’s cooldowns: skill.cooldown. On the following turns tick lowers it to 0, and problem says “ready” only then.
In the game the list of available skills is the same check, only on the hero of the game:
static List<Skill> usableSkills(Hero hero) { List<Skill> result = new ArrayList<>(); for (String skillId : hero.skills) { Skill skill = SKILLS.get(skillId); if (hero.mana >= skill.mana && hero.cooldownOf(skill.id) == 0) result.add(skill); } return result;}More precisely, if you are curious
In the game mana returns by one on every step on the map, not on a turn in battle, and also with a potion and on a new level. So in battle it is a stock that has to last: if you spend it on Cleave on the first turn, you do not have it when the boss asks for it. The cooldown is counted on the fighter’s own turns (tick is at the start of its turn), not on rounds. So a faster fighter, who acts more often, recharges more often.
The idea
Targets
A skill has a target: one (one enemy), all (all living ones) or self (the hero). SkillAction collects the targets in a list and applies the effect to each: with all that is all the living enemies, otherwise it is one target. That is why Cleave strikes both the goblin and the rat with one command, and Smash only one.
But who chooses the target? The player types a number: the enemy is 1 or 2 among the living ones, and without a number it is the first. The program turns the player’s line into a command in parse:
// The target is a number from 1 among the living enemies; without a number it is the first.static Fighter aim(Battle battle, String[] parts, int index) { List<Fighter> alive = battle.aliveEnemies(); if (parts.length <= index) return alive.get(0); String text = parts[index]; if (text.length() != 1 || !Character.isDigit(text.charAt(0))) return null; int number = text.charAt(0) - '0'; return number >= 1 && number <= alive.size() ? alive.get(number - 1) : null;}
// An invalid command costs no turn: it says why and asks again.static BattleAction refuse(Battle battle, String line, String reason) { System.out.println("? " + line.trim() + ": " + reason); return null;}
static BattleAction parse(Battle battle, Fighter hero, String line) { String[] parts = line.trim().split("\\s+"); String word = parts[0]; if (word.equals("attack")) { Fighter target = aim(battle, parts, 1); return target == null ? refuse(battle, line, "no such target") : new AttackAction(hero, target); } if (word.equals("skill")) { Skill skill = parts.length > 1 ? skillOf(battle, parts[1]) : null; if (skill == null) return refuse(battle, line, "unknown skill"); String why = problem(battle, hero, skill); if (!why.isEmpty()) return refuse(battle, line, why); Fighter target = aim(battle, parts, 2); return target == null ? refuse(battle, line, "no such target") : new SkillAction(hero, skill, target); } if (word.equals("item")) { return hero.potions > 0 ? new ItemAction(hero, "potion") : refuse(battle, line, "no potions"); } if (word.equals("flee")) return new FleeAction(hero); return refuse(battle, line, "unknown command");}Notice that a wrong command costs no turn. parse says the reason (an unknown skill, no such target, the skill is recharging) and returns nothing, and Player reads the next line. A command that was not made is not recorded and not executed, so the battle never learns about it. It is the same rule as bumping into a wall on the map.
The program reads the commands from standard input, one per line. Some of them are wrong on purpose:
skill fireballattack 5skill cleaveskill cleavedanceskill smash 2itemattack 1attackBattle battle = newBattle(7);battle.live = true;List<String> lines = new ArrayList<>();Scanner in = new Scanner(System.in);while (in.hasNextLine()) { String line = in.nextLine(); if (!line.isBlank()) lines.add(line);}run(battle, new Player(lines));== Round 1 ==? skill fireball: unknown skill? attack 5: no such targetWarrior: CleaveWarrior → Goblin: 6 damageWarrior → Rat: 6 damageGoblin → Warrior: 8 damageRat → Warrior: 4 damage== Round 2 ==? skill cleave: recharging: 2? dance: unknown commandWarrior: SmashWarrior → Rat: 12 damageRat falls.Goblin → Warrior: 7 damage== Round 3 ==Warrior: potion, +10 lifeGoblin → Warrior: 7 damage== Round 4 ==Warrior → Goblin: 6 damageGoblin → Warrior: 6 damage== Round 5 ==Warrior → Goblin: 7 damageGoblin falls.Victory!The lines with ? are the refusals: there is no skill “fireball”, there is no fifth enemy, Cleave is still recharging, “dance” is not a command. None of them passed through perform, so none is in the log or in the record.
In the game, choosing a target is a separate screen over the battle, transparent, which does not strike by itself: it returns the number of the target with pop(result), and the battle decides what to do with it (the lesson on screens that return an answer). It looks like this:
=== Battle ===Iva, barbarian lv1 L [############] 48/48 M [########] 4/41) Goblin spearman [########] 16/162) Goblin shaman [########] 13/13 Battle! Against you: goblin spearman, goblin shaman.1 attack 2 skill 3 item 4 flee
+- Target -------------------------+ | 1) goblin spearman 16/16 | | 2) goblin shaman 13/13 | | | | number: target, x: back | +----------------------------------+// Choosing a target is a transparent screen over the battle. It does not attack: pop(result) returns the target number,// and the battle decides what to do with it.static class TargetScreen extends Screen { Battle battle; String note;
TargetScreen(Game game, Battle battle) { super(game); this.battle = battle; this.note = ""; }
@Override boolean isTransparent() { return true; }
@Override void draw(CellBuffer buffer) { List<Enemy> enemies = this.battle.aliveEnemies(); buffer.box(24, 9, 36, enemies.size() + 4, t("target.title")); for (int i = 0; i < enemies.size(); i++) { buffer.text(26, 10 + i, (i + 1) + ") " + pad(enemies.get(i).name, 22) + " " + enemies.get(i).hp + "/" + enemies.get(i).maxHp(), enemies.get(i).kind.color); } buffer.text(26, 11 + enemies.size(), this.note.length() > 0 ? this.note : t("target.help"), "gray"); }
@Override void handle(String command) { if (command.equals("x")) { this.game.stack.pop(null); return; } Integer choice = parseIntStrict(command); if (choice == null || choice < 1 || choice > this.battle.aliveEnemies().size()) { this.note = t("ui.unknown", "command", command); return; } this.game.stack.pop(choice - 1); }}The battle screen keeps what was started (pending), and onResult finishes the command when the answer comes: it checks mana and cooldown, opens a second screen for the target if needed, and finally calls heroAct with the ready command:
void onResult(Object result) { Battle battle = this.battle; Hero hero = battle.hero; Pending pending = this.pending; this.pending = null; if (result == null || pending == null) return; int index = (Integer) result; if (pending.kind.equals("attack")) { battle.heroAct(new AttackAction(hero, battle.aliveEnemies().get(index))); } else if (pending.kind.equals("skill")) { Skill skill = SKILLS.get(hero.skills.get(index)); if (hero.mana < skill.mana) { this.note = t("battle.noMana", "skill", nm(skill)); } else if (hero.cooldownOf(skill.id) > 0) { this.note = t("battle.cooldown", "skill", nm(skill), "turns", hero.cooldownOf(skill.id)); } else if (skill.target.equals("one") && battle.aliveEnemies().size() > 1) { this.pending = new Pending("skillTarget", skill); this.game.stack.push(new TargetScreen(this.game, battle)); } else { battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies().get(0))); } } else if (pending.kind.equals("skillTarget")) { battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies().get(index))); } else { battle.heroAct(new ItemAction(hero, this.potionIds().get(index))); }}The idea
Who chooses: the chooser
The commands exist now. Who chooses them? Every chooser is an object with the same method choose(battle, actor), which returns a command or nothing (the end of the input):
// Who chooses the command: the artificial intelligence, the player, or the record.interface Chooser { BattleAction choose(Battle battle, Fighter actor);}
static class Auto implements Chooser { public BattleAction choose(Battle battle, Fighter actor) { return actor == battle.hero() ? chooseHero(battle) : chooseEnemy(battle, actor); }}
static class Player implements Chooser { final List<String> lines; int next = 0;
Player(List<String> lines) { this.lines = lines; }
public BattleAction choose(Battle battle, Fighter actor) { if (actor != battle.hero()) return chooseEnemy(battle, actor); while (next < lines.size()) { BattleAction action = parse(battle, actor, lines.get(next++)); if (action != null) return action; } return null; }}
static class Replay implements Chooser { final List<ActionRecord> record; int next = 0;
Replay(List<ActionRecord> record) { this.record = record; }
public BattleAction choose(Battle battle, Fighter actor) { if (next >= record.size()) return null; return actionFromRecord(battle, record.get(next++)); }}
static Fighter weakest(Battle battle) { Fighter best = null; for (Fighter enemy : battle.aliveEnemies()) { if (best == null || enemy.hp < best.hp) best = enemy; } return best;}
static BattleAction chooseEnemy(Battle battle, Fighter enemy) { return new AttackAction(enemy, battle.hero());}
static BattleAction chooseHero(Battle battle) { Fighter hero = battle.hero(); Fighter target = weakest(battle); if (hero.hp * 100 / hero.maxHp < 35 && hero.potions > 0) return new ItemAction(hero, "potion"); List<Skill> usable = usableSkills(battle, hero); for (Skill skill : usable) { if (skill.target().equals("all") && battle.aliveEnemies().size() >= 2) return new SkillAction(hero, skill, target); } for (Skill skill : usable) { if (skill.target().equals("one")) return new SkillAction(hero, skill, target); } return new AttackAction(hero, target);}Autochooses for everyone: the enemies strike the hero, and the hero decides by priority: a potion when life is low, a skill for many targets, a strong strike, a plain strike. This is how the simulation runs. It is the simplest “utility scoring”; the smarter decisions of the enemies are the topic of the next lesson (Enemies that think);Playergives the hero’s commands from the input, and the enemies stay with the artificial intelligence;Replaywe will see in a moment.
The battle loop run does not know who is who. It runs rounds, tells every fighter “it is your turn” and takes a command from the chooser. So who chooses and what is executed are two independent things: the player, the autopilot and the record are swapped without touching the battle.
// One battle: rounds, and in each round everybody takes a turn in order. The command comes from the chooser.static void run(Battle battle, Chooser chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say("== Round " + battle.rounds + " =="); for (Fighter actor : battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); BattleAction action = chooser.choose(battle, actor); if (action == null) { battle.over = true; break; } battle.perform(action); } }}
static BattleAction actionFromRecord(Battle battle, ActionRecord r) { Fighter actor = battle.fighters.get(r.actor()); Fighter target = r.target() >= 0 ? battle.fighters.get(r.target()) : null; if (r.action().equals("attack")) return new AttackAction(actor, target); if (r.action().equals("skill")) return new SkillAction(actor, skillOf(battle, r.id()), target); if (r.action().equals("item")) return new ItemAction(actor, r.id()); return new FleeAction(actor);}
static String describe(ActionRecord r) { return r.actor() + " " + r.action() + " " + r.target() + (r.id().isEmpty() ? "" : " " + r.id());}
// The replay: a new start from the same seed, and the record instead of a choice.static Battle replayBattle(int seed, List<ActionRecord> record) { Battle battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
static boolean sameLines(List<String> first, List<String> second) { if (first.size() != second.size()) return false; for (int i = 0; i < first.size(); i++) { if (!first.get(i).equals(second.get(i))) return false; } return true;}See it
Record and replay
The commands now pass through perform, and that is where they are recorded. What is left is to play them again. A replay is a new battle from the same start (the same seed, the same fighters) in which the chooser is Replay: it hands out the recorded commands one by one. On the scene above is the live battle, in the middle is the tape with the record, below is the replay.
// The only place every command passes through: it is recorded first, then executed.void perform(BattleAction action) { record.add(action.toRecord(this)); action.execute(this); checkEnd();}
// One battle: rounds, and in each round everybody takes a turn in order. The command comes from the chooser.static void run(Battle battle, Chooser chooser) { while (!battle.over && battle.rounds < 12) { battle.rounds++; battle.say("== Round " + battle.rounds + " =="); for (Fighter actor : battle.fighters) { if (battle.over) break; if (!actor.alive()) continue; actor.tick(); BattleAction action = chooser.choose(battle, actor); if (action == null) { battle.over = true; break; } battle.perform(action); } }}
// The replay: a new start from the same seed, and the record instead of a choice.static Battle replayBattle(int seed, List<ActionRecord> record) { Battle battle = newBattle(seed); run(battle, new Replay(record)); return battle;}
static boolean sameLines(List<String> first, List<String> second) { if (first.size() != second.size()) return false; for (int i = 0; i < first.size(); i++) { if (!first.get(i).equals(second.get(i))) return false; } return true;}Step by step, in words
- The battle runs rounds. One fighter is up and the chooser gives a command: the artificial intelligence (or the player) for the hero, a strike for the enemies. The battle does not ask what the command is: it just takes it.
- perform records the command and executes it. The first is the skill from the table (0 skill 2): mana drops to 5; the goblin’s life goes 18 → 12, the rat’s 10 → 4. The chip on the tape is its record.
- Every command, the hero’s and the enemies’, goes through the same perform and drops a chip on the tape. The record is only data: who (a number), what, against whom. It holds no life, no damage and no randomness.
- The battle is over: 10 commands in the record (the tape shows the first 8). The replay starts again from the same seed with the same fighters at full life. Only the choice is different: the commands come from the record, not from a player and an artificial intelligence.
- Replay.choose hands out the next chip as a command, and run and perform are the same as live. The generator has the same seed, so every strike comes out the same and the numbers in the lower battle repeat the upper one turn by turn.
- sameLines compares the two logs line by line: 21 lines and not a single difference. So a replay can be checked, not only watched.
- The seed and the record are the whole battle. The same loop, run without a screen thousands of times, is also the simulation for balance.
The program plays a battle with the autopilot, prints the record and replays it twice: with the same seed and with another.
Battle battle = newBattle(7);run(battle, new Auto());System.out.println("Record:");for (ActionRecord r : battle.record) { System.out.println(describe(r));}Battle again = replayBattle(7, battle.record);System.out.println("Same battle from the record: " + (sameLines(battle.log, again.log) ? "yes" : "no"));Battle other = replayBattle(8, battle.record);System.out.println("Same battle from another seed: " + (sameLines(battle.log, other.log) ? "yes" : "no"));Record:0 skill 2 cleave1 attack 02 attack 00 skill 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Same battle from the record: yesSame battle from another seed: noThe first replay matches the battle line by line, the second does not. The record keeps the choices (who, what, against whom), not the dice. Damage and hits come from the seeded generator, so the same record on another seed gives the same choices on other numbers. That is also why the record is small: 10 commands, not 21 log lines.
In the game the replay is a screen after a victory (the key r). It shows the same log page by page and an answer whether it matches the battle:
=== Battle replay (1/1) === Battle! Against you: giant rat. Giant rat scratches Iva (1). Iva misses giant rat. Giant rat scratches Iva (1). Iva smashes giant rat (12). Giant rat falls and does not rise. Gold: +1. Giant rat leaves behind: antidote.The replay matches the battle: yes(Enter: back)static BattleAction actionFromRecord(Battle battle, ActionRecord record) { Creature actor = battle.creatureAt(record.actor); Creature target = record.target >= 0 ? battle.creatureAt(record.target) : null; if (record.action.equals("attack")) return new AttackAction(actor, target); if (record.action.equals("skill")) return new SkillAction((Hero) actor, SKILLS.get(record.id), target); if (record.action.equals("item")) return new ItemAction((Hero) actor, record.id); if (record.action.equals("flee")) return new FleeAction(actor); return new SpecialAction((Enemy) actor, record.id, target);}
// Returns the log of the replayed battle.static List<String> replayBattle(BattleSnapshot snapshot, List<ActionRecord> record) { ReplayWorld world = new ReplayWorld(snapshot); List<Enemy> enemies = new ArrayList<>(); for (Enemy enemy : snapshot.enemies) enemies.add(enemy.copy()); Battle battle = new Battle(world, enemies, snapshot.canFlee); battle.script = record; battle.start(); battle.advance(); return battle.log;}
static boolean sameLines(List<String> first, List<String> second) { if (first.size() != second.size()) return false; for (int i = 0; i < first.size(); i++) { if (!first.get(i).equals(second.get(i))) return false; } return true;}More precisely, if you are curious
In the game the replay does not start from the seed of the world, but from a snapshot of the start of the battle: the state of the generator and copies of the hero and the enemies (with the hero’s skills, mana and items at that moment). The hero has an inventory and a level that are not built from a seed, so the snapshot carries them. The replay is a Battle with a script instead of a player and an artificial intelligence: advance takes the commands from the record. The world of the replay (ReplayWorld) does not change: no corpses, gold or quests, only the log remains.
A record depends on the version of the rules: if you change the damage of Cleave, old records no longer match. So records are good for checking and for hunting bugs in the same version, not for permanent storage.
The idea
A thousand battles without a screen
If you run run with Auto for both sides, you need no screen, no player and no record. The program plays battles with seeds from 1 to 1000 and counts the wins:
System.out.println("Simulation without a screen");int wins = 0;int losses = 0;int commands = 0;int total = 1000;for (int seed = 1; seed <= total; seed++) { Battle battle = newBattle(seed); run(battle, new Auto()); if (battle.outcome.equals("win")) wins++; else losses++; commands += battle.record.size();}int average = commands * 10 / total;System.out.println("Battles: " + total + ", wins: " + wins + ", losses: " + losses + ", average commands: " + average / 10 + "." + average % 10);Simulation without a screenBattles: 1000, wins: 875, losses: 125, average commands: 9.9The result is 875 wins and 125 defeats. This is how balance is measured: if the hero wins every battle, the enemies are weak; if the hero loses, they are too strong; the numbers are nudged until the outcome is what you want. Everything is deterministic: the same code gives the same numbers every time and in every language.
In the game this is --simulate=1000: battles for every class against every enemy, as you will see in the last lesson. The base is this: the hero on autopilot (autoHero) chooses commands with the same scoring, and the battle runs with the same advance, without a screen:
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);}The simulation is possible because the battle writes to a log, not to the screen (in the lesson live only copies it there), and the choice of command is replaceable.
Try it
Predict the output
On every turn the hero uses the last available skill (Smash if possible, otherwise Cleave). Cleave costs 3 mana and recharges for 3 turns, Smash costs 4 and recharges for 4, and the hero starts with 8 mana. What does the program print?
Battle battle = newBattle(7);Fighter hero = battle.hero();for (int turn = 1; turn <= 5; turn++) { hero.tick(); String used = "-"; for (Skill skill : usableSkills(battle, hero)) used = skill.id(); if (!used.equals("-")) { battle.perform(new SkillAction(hero, skillOf(battle, used), battle.fighters.get(1))); } System.out.println(turn + " " + used);}Turn 1: both are available, the last one is taken, Smash (mana 8 → 4, cooldown 4). Turn 2: Smash is recharging (3), what remains is Cleave and 4 mana is enough for it (mana 1). From turn 3 the mana is 1 and not enough for anything, even on turn 5, when Smash has already recharged. The other outputs belong to a program without cooldowns (Smash would repeat on turn 2) and to a program that does not spend mana (on turn 5 Smash would be available again).
Try it
Find the bug
A colleague changed SkillAction and now the replay does not match the battle. The record looks strange:
class SkillAction implements BattleAction { final Fighter actor; final Skill skill; final Fighter target;
SkillAction(Fighter actor, Skill skill, Fighter target) { this.actor = actor; this.skill = skill; this.target = target; }
public ActionRecord toRecord(Battle battle) { return new ActionRecord(battle.indexOf(actor), "attack", battle.indexOf(target), skill.id()); }
public void execute(Battle battle) { actor.mana -= skill.mana(); actor.cooldowns.put(skill.id(), skill.cooldown()); battle.say(actor.name + ": " + skill.name()); List<Fighter> targets = skill.target().equals("all") ? battle.aliveEnemies() : List.of(target); for (Fighter victim : targets) { battle.hit(actor, victim, actor.power * skill.percent() / 100 + battle.rng.roll(3)); } }}Battle battle = newBattle(7);run(battle, new Auto());System.out.println("Record:");for (ActionRecord r : battle.record) { System.out.println(describe(r));}Battle again = replayBattle(7, battle.record);System.out.println("Same battle from the record: " + (sameLines(battle.log, again.log) ? "yes" : "no"));Record:0 attack 2 cleave1 attack 02 attack 00 attack 2 smash1 attack 00 item -1 potion1 attack 00 attack 11 attack 00 attack 1Same battle from the record: noIn the record Cleave is attack. Which line is to blame?
Pick the line with the bug.
toRecord writes "attack" instead of "skill". Live, the skill is executed as a skill: the mana is paid, the strike hits everyone. But the record says “strike” against one target, and the replay does exactly what it reads: it strikes, without paying mana and without a cooldown. The logs differ from the very first command. The sign is in the record itself: lines like 0 attack 2 cleave, where the kind and the skill contradict each other.
More precisely
What a command cannot do
A command as an object is handy, but it has limits worth knowing.
The class does not protect itself from an illegal command. SkillAction does not check mana, because parse (for the player) and usableSkills (for the artificial intelligence) check it before they create it. If you bypass those checks somewhere, mana goes negative. The other choice is to put the check inside the command, which returns “I cannot”. The game chooses the first: an illegal command is never created.
A record depends on the rules. It keeps the choices, not the consequences. If you change the damage formula or add one more dice roll, old records give another battle. The order of the calls to the generator is part of the contract.
Randomness has to be in the seed. If part of the battle uses the language’s Random or the clock, the replay diverges. That is why the game has its own generator, and damage and hits are rolled in only a few places.
A command is not an event. An event says “something happened” (an enemy died), a command says “do”. The event bus is another lesson (the quests), and the two often work together: a command changes the battle, and the battle fires an event.
One class per kind does not grow forever. For four kinds of commands it is fine, for eighteen skills it is too many. That is why skills are data, and SkillAction is one class for all of them.
Exercise
Rest
Solve the task in your editor. Start from the program with the player from this lesson.
Rest. Add a fourth kind of command for the hero: rest gives back 2 mana (not more than the maximum) and costs the turn. The player types it as rest. Three places have to be touched: the class RestAction itself (with a toRecord that returns rest), parse (the player’s word) and actionFromRecord (the record). At the end the program plays the replay and prints whether it matches. The input (one per line) is `skill smash 2`, `dance`, `rest`, `item`, `skill smash 1`, `attack 1`, `skill smash 1`, and the output is:
skill smash 2dancerestitemskill smash 1attack 1skill smash 1== Round 1 ==Warrior: SmashWarrior → Rat: 12 damageRat falls.Goblin → Warrior: 8 damage== Round 2 ==? dance: unknown commandWarrior: rest +2 manaGoblin → Warrior: 8 damage== Round 3 ==Warrior: potion, +10 lifeGoblin → Warrior: 6 damage== Round 4 ==? skill smash 1: recharging: 1Warrior → Goblin: 8 damageGoblin → Warrior: 7 damage== Round 5 ==Warrior: SmashWarrior → Goblin: 11 damageGoblin falls.Victory!Same battle from the record: yesMore tasks
- A healing skill. Add to the table a skill that heals the hero (target
self). How many lines of code have to change, and why so few? - Escape.
FleeActionrolls dice (50%). Givefleein the player’s input and look at the replay: does it match? Why are the dice not in the record? - Undo. Try to add an
undo()method toAttackActionandItemAction. Which of the commands cannot be undone without a snapshot of the state? - Balance. Raise the goblin’s strength by 1 and run the simulation. How much do the wins change, and why are the numbers from a thousand battles and not from one?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lists · HashMap and HashSet · Lambdas · record, switch and patterns
- Tasks on this topic: Chronicles of Arcanum