Към съдържанието
wannacode

Цялата игра и балансът

Накратко: Балансът се мери със симулация: хиляди боеве със семе, процент победи за всяка двойка. Числата се настройват едно по едно с обход при същото семе, а тест със записано минаване на цялата игра хваща всяка промяна в поведението.

Проблем

Добра ли е играта?

Играта е готова. Картата се открива, боят върви по бързина, раните кървят, враговете гонят, записът се зарежда. Остава въпросът, на който кодът не отговаря: добра ли е? Може ли вещицата да победи Пазача на дълбините, или варваринът го бие без усилие? Ако играеш ти, ще изиграеш десетина боя и ще имаш мнение. Но десет боя са шум: в този урок един и същ въпрос („варварин срещу Пазача“) дава 30% при едно семе и 55% при друго, когато боевете са само 20.

Трябва начин да се изиграят хиляди боя за секунди, всеки път по един и същи начин, без да се рисува нищо. Това е симулация: боевете се пускат без екран, изкуствен интелект срещу изкуствен интелект, а резултатът е таблица от проценти. С нея се търсят числата, които играта още не е получила: колко живот да има Пазачът, за да е труден, но победим.

Урокът го прави два пъти. Първо на малка играчка, за да се види всяка стъпка и да я пуснеш на четирите езика: боят, таблицата, шумът, настройката и тестът със запис. После на истинската игра, която вече има --simulate. Накрая стоят цялата игра, за да я играеш и изтеглиш, и идеи как да я продължиш.

Идеята

Бой без екран

Правилата на боя в играта не знаят за екрана: екранът само ги вика и рисува резултата (урокът за стека от екрани). Затова същите правила могат да се пуснат и без екран. Играчката по-долу е най-малкото такова нещо: тримата героя срещу четирима врага, всеки с четири числа (живот, точност, най-малка и най-голяма щета). Няма бързина, критични удари и ефекти: тези са в бойната система, а тук е нужна само формата.

Първо генераторът. В играта е mulberry32 от първия урок; тук е още по-малък (на Парк и Милър), за да се събере, но върши същото: същото семе, същите числа.

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

state * 48271 надвишава int, затова състоянието е long. Остатъкът % n дава число от 0 до n − 1.

Ударът и боят:

// Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 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;
}
// Боят: героят удря пръв. Свършва, когато някой остане без живот.
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);
}
}

Ударът хвърля число от 0 до 99. Ако е по-малко от точността (hit), това е попадение и щетата е случайно число между най-малката и най-голямата. Иначе е пропуск. Боят дава ход на героя, после на врага, докато някой остане без живот. Връща победа или загуба и колко хода е направил героят.

// Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 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;
}

Идеята

Таблицата на баланса

Един бой не казва нищо. Затова боевете се пускат много пъти с общ генератор: победите и ходовете на героя се събират.

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;
}
// Много боеве с един общ генератор: победите и ходовете на героя.
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);
}

Процентът е цяло деление (победи × 100 ÷ боеве, надолу), а средните ходове са в десети: turns * 10 / fights, после X.Y. Така няма дробни числа и изходът е еднакъв на четирите езика. Таблицата е simulate: редове са враговете, колони са героите.

// Таблицата: редове са враговете, колони са героите.
static void simulate(Fighter[] heroes, Fighter[] enemies, int fights, long seed) {
Rng rng = new Rng(seed);
String header = String.format("%-10s", "Враг");
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", "Средно");
for (int total : totals) average += String.format("%12s", total / enemies.length + "%");
System.out.println(average);
}
Fighter[] heroes = {
new Fighter("Варварин", 40, 70, 4, 9),
new Fighter("Амазонка", 40, 80, 3, 7),
new Fighter("Вещица", 24, 85, 3, 8),
};
Fighter[] enemies = {
new Fighter("Плъх", 9, 60, 1, 3),
new Fighter("Вълк", 18, 70, 2, 5),
new Fighter("Голем", 40, 60, 3, 6),
new Fighter("Пазач", 54, 70, 3, 7),
};
simulate(heroes, enemies, 200, 7);
Изход
Враг Варварин Амазонка Вещица
Плъх 100% 2.7 100% 2.7 100% 2.5
Вълк 100% 4.6 100% 5.0 98% 4.4
Голем 98% 9.5 93% 10.6 64% 8.0
Пазач 54% 11.0 35% 11.3 7% 7.2
Средно 88% 82% 67%

Плъхът и вълкът се побеждават почти винаги, големът вече боли вещицата. Пазачът е шефът: варваринът го бие в 54%, амазонката в 35%, вещицата в 7%. Ако искаме Пазачът да е труден, но победим за всеки клас, един герой е извън лентата.

Виж го

Колко боя са нужни и как се настройва числото

Сцената показва два въпроса. Горе: колко боя са достатъчни, за да се успокои процентът? Долу: как се вкарва вещицата в лентата, като се променя едно число?

// Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 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;
}
// Боят: героят удря пръв. Свършва, когато някой остане без живот.
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;
}
// Много боеве с един общ генератор: победите и ходовете на героя.
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);
}
// Процентът победи при нов генератор от даденото семе.
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);
}
// Лентата на баланса: Пазачът се побеждава в 30 до 60 от 100 боя.
static boolean inBand(int rate) {
return rate >= 30 && rate <= 60;
}
// Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.
static void sweep(Fighter warden, int fights, long seed) {
for (int life = 24; life <= 40; life += 4) {
Fighter witch = new Fighter("Вещица", life, 85, 3, 8);
int rate = winRate(witch, warden, fights, seed);
System.out.println(witch.name() + ", живот " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : ""));
}
}
Стъпка по стъпка, с думи
  1. Един бой не казва нищо. Варваринът срещу Пазача е веднъж победа, веднъж загуба: след първия бой процентът е 0%, след втория 50%. Всяка точка е процентът победи след толкова боя (скалата е логаритмична).
  2. Цикълът събира победите: 60% след 5 боя, 37% след 8, 41% след 12. Точките още скачат, защото всеки бой е хвърляне на зарове.
  3. Процентът е победи × 100 ÷ боеве, с цяло деление. След 50 боя е 40%, след 200 48%: колебанията стават по-малки.
  4. След 1000 боя е 48% и зелената линия показва къде се е установило. Същият въпрос с пет различни семена: при 20 боя отговорите са между 30% и 55%, при 1000 между 48% и 51%.
  5. Таблицата от 200 боя срещу Пазача: варварин 54%, амазонка 35%, вещица 7%. Лентата е 30 до 60: двама са вътре, вещицата е далеч под нея.
  6. Настройка: променя се само едно число, животът на вещицата, а всеки ред е със същото семе. При 24 живот е 5% (таблицата даваше 7%: друг генератор, друг късмет).
  7. 28 живот: 13%, 32: 21%. Лентата ѝ расте с живота, но още е под 30.
  8. 36 живот: 36%, лентата става зелена: вътре е. Най-малката промяна, която върши работа.
  9. 40 живот: 51%: и това е вътре, но вещицата вече побеждава Пазача по-често от амазонката (35%). Обходът показва цялата крива, изборът е твой.
  10. Проверка на избора, 36 живот, с 1000 боя: 38%. Все още е вътре, а числото е по-стабилно от това с 200 боя.

Кодът до сцената е боят, цикълът на боевете и обходът от програмите на урока. На всяка стъпка светят редовете, които работят в този момент.

Идеята

Шумът: същият въпрос, друго семе

Процентът от симулация е оценка, не истина. Всяко семе дава друга поредица от хвърляния, а с нея и друго число. Колко се различават отговорите? Ето един и същ въпрос (варварин срещу Пазача) с пет семена и два размера:

// Процентът победи при нов генератор от даденото семе.
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("Варварин", 40, 70, 4, 9);
Fighter warden = new Fighter("Пазач", 54, 70, 3, 7);
System.out.println(barbarian.name() + " срещу " + warden.name() + ":");
for (int fights : new int[] { 20, 1000 }) {
String line = fights + " боя:";
for (long seed = 1; seed <= 5; seed++) {
line += " " + winRate(barbarian, warden, fights, seed) + "%";
}
System.out.println(line);
}
Изход
Варварин срещу Пазач:
20 боя: 50% 45% 55% 50% 30%
1000 боя: 51% 51% 48% 50% 48%

При 5 семена и 20 боя отговорите са между 30% и 55%: разлика от 25 пункта за един и същ въпрос. При 1000 боя са между 48% и 51%, разлика 3. Правилото: шумът намалява с корена от броя на боевете. За два пъти по-малка грешка трябват четири пъти повече боя.

Две различни неща не бива да се смесват. Повторяемост: същото семе дава точно същото число, затова тестът и колегата виждат едно и също. Шум: две различни семена дават различни числа и въпросът е кое да се вярва. Повторяемостта е свойство на кода, шумът е свойство на въпроса.

Истинската игра е същата. Пускането със 200 боя (както в записания изход на проверката) дава за Пазача 41%, 58% и 55%, а с 1000 боя 44%, 58% и 54%. Варваринът се мести с 3 пункта, без да е променено нищо в играта.

Идеята

Настройка: едно число наведнъж

Нека целта е записана преди да пипаш числата: Пазачът се побеждава в 30 до 60 от 100 боя. Тази лента е мярката, а не мнение в момента. Вещицата е под нея. Какво да се промени? Вариантите са много (повече живот, по-силна магия, по-слаб Пазач), но правилото е едно: променяй едно число и сравнявай при същото семе. Иначе не знаеш кое е свършило работа.

Обходът пуска вещицата с 24, 28, 32, 36 и 40 живот. Всеки ред е със същото семе, така че редовете се различават само по живота:

// Лентата на баланса: Пазачът се побеждава в 30 до 60 от 100 боя.
static boolean inBand(int rate) {
return rate >= 30 && rate <= 60;
}
// Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.
static void sweep(Fighter warden, int fights, long seed) {
for (int life = 24; life <= 40; life += 4) {
Fighter witch = new Fighter("Вещица", life, 85, 3, 8);
int rate = winRate(witch, warden, fights, seed);
System.out.println(witch.name() + ", живот " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : ""));
}
}
Fighter warden = new Fighter("Пазач", 54, 70, 3, 7);
sweep(warden, 200, 7);
Fighter tuned = new Fighter("Вещица", 36, 85, 3, 8);
int tunedRate = winRate(tuned, warden, 1000, 7);
System.out.println(tuned.name() + ", живот 36, 1000 боя: " + tunedRate + "%" + (inBand(tunedRate) ? " ✓" : ""));
Изход
Вещица, живот 24: 5%
Вещица, живот 28: 13%
Вещица, живот 32: 21%
Вещица, живот 36: 36% ✓
Вещица, живот 40: 51% ✓
Вещица, живот 36, 1000 боя: 38% ✓

Кривата расте с живота. При 36 живот вещицата е 36% и влиза в лентата; при 40 е 51%, което е вече повече от амазонката (35%). Избира се най-малката промяна, която върши работа: 36. Последният ред проверява избора с 1000 боя: 38%, все още вътре. Първият ред (24 живот) дава 5%, а таблицата за същия герой 7%: различен поток от числа, различен късмет, а не различна игра.

След настройката се записва пак минаването на играта (следващата карта), за да се види, че е сменено само онова, което си искал.

Идеята

Същото на истинската игра

Играчката има четири числа на боец. Истинската игра има стотици, но формата е същата и тя я има готова. Регионът simulate е същото нещо, но върху истинската игра:

// Балансът: стотици боеве без екран, изкуствен интелект срещу изкуствен интелект. Героят е с
// ниво според дълбочината на врага; таблицата казва процента победи и средните ходове.
static final int[] SIM_LEVELS = {1, 1, 3, 5, 7};
// Симулираният герой учи най-ниската стъпка от дървото (при равенство: първия клон).
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;
}
// Изходът от един симулиран бой: победа ли е и колко хода е изиграл героят.
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");
}

Разликите от играчката са за реализма, не за идеята. Героят е с ниво според дълбочината на врага (SIM_LEVELS: 1, 3, 5, 7), учи умения по прост ред (simSkillChoice), от ниво 4 носи по-добра екипировка и пие отвари; боят е истинският Battle, пуснат с autoHero, т.е. героят на автопилот избира по оценката на полезността от урока за изкуствения интелект. Всичко останало е играта: нищо не е преписано за симулацията.

Пуска се с флаг: node arcanum.js --simulate=1000 --seed=7 --plain (на другите езици същото). Това е изходът, който получих за 1000 боя на двойка:

Изход
Враг Ниво Варварин Амазонка Вещица
Гигантски плъх 1 100% 2.4 100% 1.3 100% 1.2
Гоблин копиеносец 1 100% 2.4 100% 2.5 100% 2.0
Гоблин шаман 1 100% 2.4 100% 2.3 100% 2.6
Вълк кръвник 1 99% 4.0 99% 3.5 92% 3.2
Вълк водач 3 78% 10.0 93% 7.2 77% 5.8
Скелет 3 99% 11.3 100% 4.4 100% 3.0
Култист 3 99% 5.9 100% 3.5 100% 3.3
Голем от руда 5 100% 7.6 97% 14.2 99% 7.6
Пазач на дълбините 7 44% 21.0 58% 19.4 54% 12.3
Средно 91% 94% 91%

Редът на Пазача е 44%, 58% и 54%: вътре е в лентата от 40 до 60, която си поставихме за истинската игра. Така е настроена: шефът е труден, но победим за всеки клас.

Какво става, ако тръгнеш да пипаш? Враговете са данни, не код (урокът за предметите и данните от таблици). Ето реда на Пазача:

# id|bg|en|de|es|знак|цвят|живот|сила|ловкост|защита|бързина|мин|макс|вид|опит|злато|плячка|resist|weak|immune|умение|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

Животът му е числото 120. Ако го смениш на 100 и пуснеш същата симулация, Пазачът се побеждава в 60%, 70% и 72% вместо в 44%, 58% и 54%. Двайсет живота по-малко правят шефа лесен за всички. Проверката на сайта пуска и този вариант на JavaScript, затова числата тук са пуснати, не измислени.

По-точно, за любопитните

Симулацията е част от самата игра, защото иска достъп до вътрешностите: Battle и Enemy. Тя не е външен скрипт, който чете екрана. Затова и основната проверка на сайта (следващата карта) я пуска на четирите езика със семе 7 и 200 боя и сравнява с един файл.

Съзнателно проста е и една друга подробност: симулираният герой взима най-ниската стъпка от дървото на уменията. Истински играч ще избере по-умно, така че таблицата казва колко е труден Пазачът за един прост бот, а не за най-добрия играч.

Идеята

Тест със записано минаване

Как се знае, че промяната на едно число не е счупила нещо друго? Играта има тест с записано минаване (golden test, snapshot test). Файлът playthrough.txt е 230 команди, по една на ред, от създаването на героя до победата над Пазача. Първите четири:

playthrough.txt
1
Ива
1
(Enter)

Те се подават на играта като вход: --plain --seed=2026 --memory-saves (без цветове, със зададено семе, със записи в паметта вместо във файлове). Играта печата всеки кадър, а изходът е 4396 реда. Ето кадрите след първите две команди, с > команда отпред:

> 1
Нов герой
Как се казва героят ти?
До 12 знака: букви, цифри, интервал, тире.
> Ива
Нов герой
Ива, избери клас:
1) Варварин: живот 48, мана 4, сила 7, ловкост 4
Брадва, от която раните кървят. Много живот. Умение:
Ярост (сила срещу защита).
2) Амазонка: живот 34, мана 10, сила 3, ловкост 8
Копие и око на ловец: точни удари, често критични.
Умение: Двоен изстрел.
3) Вещица: живот 26, мана 20, сила 1, ловкост 5
Малко живот, много мана. Огнена топка, Отровен облак и
Лечение.

Изходът е записан във файл playthrough.bg.out. Проверката на сайта пуска играта на всеки от четирите езика с всеки от четирите езика на текстовете и сравнява байт по байт с файла: 20 сравнения заедно със симулацията. Една промяна в правилата, в реда на хвърлянията или в текст, който се печата, променя изхода, и проверката пада.

Ето малък модел на същото. Записан е бой на варварина с вълка (семе 2027); после се променя едно число (най-голямата щета на вълка, от 5 на 6) и се сравнява с записа:

// Един ред от дневника на боя.
static String describe(int turn, String who, String target, int damage, int left) {
if (damage == 0) return turn + ". " + who + " пропуска";
return turn + ". " + who + " удря " + 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);
}
}
// Първият ред, в който двата дневника се различават (0, ако са еднакви).
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) : "-";
}
// Тестът: бой със семе 2027 и сравнение със записаното.
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 + ": вярно, " + log.size() + " реда");
} else {
System.out.println(label + ": ред " + at);
System.out.println(" очаквано: " + lineAt(recorded, at));
System.out.println(" получено: " + lineAt(log, at));
}
}
List<String> recorded = List.of(
"1. Варварин удря 6 → Вълк 12",
"1. Вълк пропуска",
"2. Варварин пропуска",
"2. Вълк пропуска",
"3. Варварин пропуска",
"3. Вълк удря 3 → Варварин 37",
"4. Варварин удря 4 → Вълк 8",
"4. Вълк удря 2 → Варварин 35",
"5. Варварин удря 5 → Вълк 3",
"5. Вълк пропуска",
"6. Варварин удря 5 → Вълк 0"
);
Fighter hero = new Fighter("Варварин", 40, 70, 4, 9);
check("вълк, най-много щета 5", recorded, hero, new Fighter("Вълк", 18, 70, 2, 5));
check("вълк, най-много щета 6", recorded, hero, new Fighter("Вълк", 18, 70, 2, 6));
Изход
вълк, най-много щета 5: вярно, 11 реда
вълк, най-много щета 6: ред 6
очаквано: 3. Вълк удря 3 → Варварин 37
получено: 3. Вълк удря 5 → Варварин 35

Програмата не казва „има разлика“, а коя е първата различна линия: ред 6 от 11. До тази линия поведението е същото, затова разликата е там, където е променено, и се чете за секунди. Първите 5 реда са едни и същи, защото вълкът хвърля същите числа: променил се е само диапазонът, от който се взема щетата.

Когато тестът падне, има два случая:

  • Промяната е искана (настроил си числото). Четеш разликата, виждаш, че е само онова, което си очаквал, и записваш минаването наново.
  • Промяната е неочаквана. Това е грешка, която иначе би излязла при играча.

Никога не се записва наново, без да се чете разликата: тогава тестът пази всичко, дори грешката. И обратно: зелен тест не значи, че играта е правилна или забавна, а само че е същата като при записването.

Идеята

Цялата игра

Ето какво построихме в уроците и къде е в играта. Регионите са от JavaScript версията, редовете са на най-горно ниво:

УрокРегиониРедове
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

Всичко е около 5500 реда на всеки език; останалото са данните, текстовете, зареждането им и екраните на заглавието, града и героя. Таблицата не е пълно разпределение, а ориентир: къде да погледнеш, когато искаш да промениш нещо.

Играй я до края (тя е същата като в първия урок) и я изтегли:

Хрониките на Арканум

Мината под Сивия брод мълчи от три дни. По пътеката има кръв.

Щракни върху екрана, за да играеш с клавишите (стрелки или WASD, I, M, цифри); Enter отива на реда за команди. Записите остават в този браузър.

Файловете са: [Arcanum.java](/games/arcanum/Arcanum.java), [Arcanum.cs](/games/arcanum/Arcanum.cs), [arcanum.py](/games/arcanum/arcanum.py), [arcanum.js](/games/arcanum/arcanum.js). Сложи един в празна папка и го пусни:

Терминал
java Arcanum.java

С --simulate=1000 печата таблицата на баланса вместо да се играе, с --seed=42 светът е един и същ, с --lang=en играта е на английски (има и de и es), с --plain няма цветове. Записите са в папка saves до играта.

Идеята

Как да я продължиш

Играта е направена така, че повечето неща да се добавят, без да се пипа цикълът. Няколко идеи, подредени от най-малката към най-голямата, с мястото в кода, откъдето да започнеш:

  1. Нов враг. Един ред в таблицата на враговете (enemies-data), знак и цвят, място на картата (maps-data). После симулацията ти казва дали е прекалено лесен. Нищо в кода не се пипа.
  2. Нов клас. Ред в CLASSES_DATA, умения в skills-data и текстове на четирите езика. Симулацията показва колоната на новия клас до другите три. Настрой я с обхода от този урок, докато влезе в лентата.
  3. Нов куест. Диалог в dialogs-data и абониране към събитията (quests): мини бележка „донеси руда“ е най-краткият пример в играта.
  4. Нов ефект. Замръзване или забавяне в effects: избери правилото за натрупване (удължава ли се, събира ли се) така, както са избрани кървенето и отровата.
  5. По-умен враг. Заменяш търсенето в ширина с A* или с поле на разстоянията (виж „По-точно“ в урока за изкуствения интелект) или дай на вълците тактика в глутница.
  6. Друг изглед. Terminal е интерфейс с два вида; трети може да рисува в canvas или в прозорец, без екраните да знаят. Входът и изходът са отделени (io) точно за това.
  7. Език. Текстовете са таблици по езици: добавяш пети език с още едно копие на таблицата с преводи; ако ключ липсва, играта спира с „missing text“, а не го пропуска тихо.
  8. Нов порт. Играта е на четири езика с един изход. Петият (Rust, Go, Kotlin) се проверява със същия playthrough.out: докато не е байт по байт същият, портът не е готов.
  9. Режим на ден. Семе от датата, и всички играят един и същ свят; повторението на боя (урокът за командите) вече записва ходовете.

Каквото и да направиш, първо пусни теста със записано минаване, после променяй. Ако падне, прочети първата разлика. Това е целият урок за баланса и за поддръжката на игра в едно.

Това е краят на модула. Започнахме с въпроса „как е устроена игра“ и стигнахме до цяла игра на четири езика с кръв, бой по бързина, врагове, които мислят, запис, който се отказва, ако е развален, и инструмент, който казва дали е добра. Останалото е на теб.

Пробвай

Предвиди изхода

Функцията cell пише една клетка от таблицата: процент победи и средни ходове. Какво печата програмата?

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));
Отговор

Пробвай

Намери грешката

Колега иска да направи симулацията „по-чиста“ и дава на всеки бой нов генератор със същото семе. Таблицата вече не прилича на нищо:

Fighter[] heroes = {
new Fighter("Варварин", 40, 70, 4, 9),
new Fighter("Амазонка", 40, 80, 3, 7),
new Fighter("Вещица", 24, 85, 3, 8),
};
Fighter golem = new Fighter("Голем", 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()));
}
Изход
Варварин: 100% 10.0
Амазонка: 100% 11.0
Вещица: 0% 8.0

Срещу голема героите печелят 100%, 100% и 0%, а ходовете са цели числа. Кой ред е виновен?

Избери реда с грешката.

По-точно

По-точно: шум, ботове и записи

Колко е грешката. За процент p от n боя стандартната грешка е √(p(1 − p) / n). При p = 50% тя е 11.2 пункта при 20 боя, 3.5 при 200 и 1.6 при 1000. Два пъти повече стандартна грешка (около 95% увереност) прави интервала ±7 пункта при 200 боя. Затова разлика от 3 пункта между два варианта на настройката при 200 боя не значи нищо, а разлика от 20 значи много. Правило: пускай повече боя там, където решението е на границата.

Не настройвай по едно семе. Ако избираш числото, докато изглежда добре при семе 7, настройваш към късмета на семе 7. Проверявай с няколко семена (например три) и вземай разумната средна.

Ботът не е играч. Симулираният герой взима най-простите решения. Таблицата казва какъв е балансът за такъв бот; истински играч е по-умен (избира умения, бяга навреме) или по-глупав (не пие отвара). Затова симулацията хваща грубите грешки (един клас е безполезен, шефът е непобедим), а фината настройка е с играчи.

Процентът победи не е забавление. Освен победи се мерят средните ходове (колоната по-горе), оставащият живот, колко отвари са изпити. Бой, който се печели в 100% с 1 живот остава на героя, е друг от бой, който се печели в 100% със 40.

Какво чупи повторяемостта. Ред на обхождане на хеш-таблица, дроби, текуща дата, езикови настройки на системата, нестабилно сортиране без второ правило. Играта ги избягва (подредени колекции, цели числа, второ правило при сортиране), а проверката на четирите езика хваща всяко връщане на проблема.

Кой комит е променил поведението? Тестът е автоматичен и бърз, затова git bisect може да го пуска и да намери комита, след който записът вече не съвпада.

Колко тестове са нужни. Записаното минаване е интеграционен тест: хваща всичко, но казва само „къде е първата разлика“, не „къде е грешката“. До него стоят малките тестове на отделни правила (щета, натрупване на ефекти). Ние пуснахме и 300 игри със случайни команди при строенето на играта: без срив и без число, което не е число. Така се слагат слоеве.

Упражнение

Най-трудният враг

Реши задачата в редактора си. Тръгни от програмата за таблицата.

Най-трудният враг. След таблицата отпечатай за всеки герой врага, срещу когото процентът победи е най-малък, и този процент. Редът на героите е като в таблицата. При равни проценти печели първият враг. Генераторът е същият (един общ за всички двойки, семе 7, 200 боя), за да са числата същите като в таблицата.

Изходът е:

Изход
Най-трудният враг за Варварин: Пазач (54%)
Най-трудният враг за Амазонка: Пазач (35%)
Най-трудният враг за Вещица: Пазач (7%)

Още задачи

  • Нов клас. Добави четвърти герой (например ловец с 32 живот, точност 90 и щета от 2 до 6) и виж колоната му. Настрой го с обход, докато Пазачът е в лентата.
  • Доверие. Покажи до всеки процент стандартната грешка (√(p(1 − p) / n)), за да се вижда кои разлики са шум.
  • Автоматична настройка. Напиши функция, която увеличава живота на вещицата през 1, докато процентът срещу Пазача влезе в лентата, и я връща. Защо е нужно процентът да расте с живота, за да върши работа това търсене, и можеш ли да го ускориш с двоично търсене?
  • Аргументи. Нека --fights= и --seed= да се четат от аргументите на програмата, както го прави играта.

Тест

Провери се

1. Варваринът побеждава Пазача в 50%, 30% или някъде между тях, когато се пуснат само 20 боя с различни семена. С 1000 боя отговорите са между 48% и 51%. Защо симулацията е по-добро доказателство от няколко боя, изиграни на ръка?
2. Обходът на живота на вещицата започва всеки ред с нов генератор от същото семе. Защо?
3. След промяна на едно число в данните на врага тестът със записано минаване пада. Какво правиш?
4. Вещицата с 36 живот печели 36% при 200 боя и 38% при 1000 боя. На кое число вярваш повече?
5. Тестът със записано минаване е зелен. Какво доказва това?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·