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

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

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

Проблем

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

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

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

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

Идеята

Бой без екран

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

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

class Rng
{
long state;
public Rng(long seed)
{
state = seed;
}
public 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)
{
var rng = new Rng(seed);
string header = "Враг".PadRight(10);
foreach (Fighter hero in heroes) header += hero.Name.PadLeft(12);
Console.WriteLine(header);
var totals = new int[heroes.Length];
foreach (Fighter enemy in enemies)
{
string line = enemy.Name.PadRight(10);
for (int h = 0; h < heroes.Length; h++)
{
Tally tally = Duels(rng, heroes[h], enemy, fights);
totals[h] += Percent(tally.Wins, fights);
line += Cell(tally.Wins, fights, tally.Turns).PadLeft(12);
}
Console.WriteLine(line);
}
string average = "Средно".PadRight(10);
foreach (int total in totals) average += (total / enemies.Length + "%").PadLeft(12);
Console.WriteLine(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 bool 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)
{
var witch = new Fighter("Вещица", life, 85, 3, 8);
int rate = WinRate(witch, warden, fights, seed);
Console.WriteLine(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);
Console.WriteLine(barbarian.Name + " срещу " + warden.Name + ":");
foreach (int fights in new[] { 20, 1000 })
{
string line = fights + " боя:";
for (long seed = 1; seed <= 5; seed++)
{
line += " " + WinRate(barbarian, warden, fights, seed) + "%";
}
Console.WriteLine(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 bool 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)
{
var witch = new Fighter("Вещица", life, 85, 3, 8);
int rate = WinRate(witch, warden, fights, seed);
Console.WriteLine(witch.Name + ", живот " + life + ": " + rate + "%" + (InBand(rate) ? " ✓" : ""));
}
}
Fighter warden = new Fighter("Пазач", 54, 70, 3, 7);
Sweep(warden, 200, 7);
var tuned = new Fighter("Вещица", 36, 85, 3, 8);
int tunedRate = WinRate(tuned, warden, 1000, 7);
Console.WriteLine(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 е същото нещо, но върху истинската игра:

// Балансът: стотици боеве без екран, изкуствен интелект срещу изкуствен интелект. Героят е с
// ниво според дълбочината на врага; таблицата казва процента победи и средните ходове.
class SimResult
{
public bool Win;
public int Turns;
}
static partial class Arcanum
{
public static readonly int[] SIM_LEVELS = { 1, 1, 3, 5, 7 };
// Симулираният герой учи най-ниската стъпка от дървото (при равенство: първия клон).
public static string SimSkillChoice(Hero hero)
{
string best = "";
foreach (string skillId in LearnableSkills(hero))
{
if (best == "" || SKILLS.Get(skillId).Tier < SKILLS.Get(best).Tier) best = skillId;
}
return best;
}
public 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 != "") hero.Skills.Add(skill);
else if (heroClass.Id == "barbarian") ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 });
else if (heroClass.Id == "amazon") ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 });
else ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 });
}
if (level >= 4)
{
hero.Equipment.Set("weapon", heroClass.BetterWeapon);
hero.Equipment.Set("armor", heroClass.BetterArmor);
}
hero.Hp = hero.MaxHp();
hero.Mana = hero.MaxMana();
hero.Inventory.Add("potion_heal", level / 2);
return hero;
}
public static SimResult SimulateFight(Game game, HeroClass heroClass, EnemyKind kind)
{
game.Hero = MakeSimHero(game, heroClass, SIM_LEVELS[kind.Depth]);
Battle battle = new Battle(game, new List<Enemy> { new Enemy(game.NewId(), kind) }, false);
battle.AutoHero = true;
battle.Advance();
return new SimResult { Win = battle.Outcome == "win", Turns = battle.HeroTurns };
}
public static void Simulate(IGameIo io, int fights, uint seed)
{
Game game = new Game(io, seed);
game.Simulation = true;
io.Write(T("sim.title", ("fights", fights), ("seed", seed)) + "\n");
io.Write(T("sim.levels") + "\n\n");
string header = Pad(T("sim.enemy"), 26) + PadLeft(T("sim.level"), 5);
foreach (HeroClass heroClass in CLASSES.Values()) header += PadLeft(Cap(Nm(heroClass)), 14);
io.Write(header + "\n");
List<int> totals = new List<int>();
for (int i = 0; i < CLASSES.Count; i++) totals.Add(0);
int rows = 0;
foreach (EnemyKind kind in ENEMY_KINDS.Values())
{
if (kind.Depth == 0) continue;
rows++;
string line = Pad(Cap(Nm(kind)), 26) + PadLeft(SIM_LEVELS[kind.Depth].ToString(), 5);
int column = 0;
foreach (HeroClass heroClass in 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[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);
foreach (int total in 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.Count, b.Count); i++)
{
if (i >= a.Count || i >= b.Count || a[i] != b[i]) return i + 1;
}
return 0;
}
static string LineAt(List<string> lines, int at)
{
return at <= lines.Count ? lines[at - 1] : "-";
}
// Тестът: бой със семе 2027 и сравнение със записаното.
static void Check(string label, List<string> recorded, Fighter hero, Fighter wolf)
{
var log = new List<string>();
Fight(new Rng(2027), hero, wolf, log);
int at = FirstDifference(recorded, log);
if (at == 0)
{
Console.WriteLine(label + ": вярно, " + log.Count + " реда");
}
else
{
Console.WriteLine(label + ": ред " + at);
Console.WriteLine(" очаквано: " + LineAt(recorded, at));
Console.WriteLine(" получено: " + LineAt(log, at));
}
}
var recorded = new List<string>
{
"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). Сложи един в празна папка и го пусни:

Терминал
dotnet new console -n Arcanum
cd Arcanum
# смени Program.cs с Arcanum.cs
dotnet run

С --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;
}
Console.WriteLine(Cell(1, 3, 7));
Console.WriteLine(Cell(2, 3, 10));
Console.WriteLine(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);
foreach (Fighter hero in heroes)
{
Tally tally = DuelsBug(7, hero, golem, 200);
Console.WriteLine(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 за достъпно обучение по програмиране ·