Цялата игра и балансът
Накратко: Балансът се мери със симулация: хиляди боеве със семе, процент победи за всяка двойка. Числата се настройват едно по едно с обход при същото семе, а тест със записано минаване на цялата игра хваща всяка промяна в поведението.
Проблем
Добра ли е играта?
Играта е готова. Картата се открива, боят върви по бързина, раните кървят, враговете гонят, записът се зарежда. Остава въпросът, на който кодът не отговаря: добра ли е? Може ли вещицата да победи Пазача на дълбините, или варваринът го бие без усилие? Ако играеш ти, ще изиграеш десетина боя и ще имаш мнение. Но десет боя са шум: в този урок един и същ въпрос („варварин срещу Пазача“) дава 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;
}Хвърлянето е от 0 до 99, а точност 70 значи 70 от 100. Числата, които печелят, са 0 до 69: по-малко от 70. При <= бяха 71 и всеки удар щеше да е с 1% по-точен от написаното. Такава грешка не се вижда в нито един бой, но се вижда в таблицата.
Идеята
Таблицата на баланса
Един бой не казва нищо. Затова боевете се пускат много пъти с общ генератор: победите и ходовете на героя се събират.
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) ? " ✓" : "")); }}Стъпка по стъпка, с думи
- Един бой не казва нищо. Варваринът срещу Пазача е веднъж победа, веднъж загуба: след първия бой процентът е 0%, след втория 50%. Всяка точка е процентът победи след толкова боя (скалата е логаритмична).
- Цикълът събира победите: 60% след 5 боя, 37% след 8, 41% след 12. Точките още скачат, защото всеки бой е хвърляне на зарове.
- Процентът е победи × 100 ÷ боеве, с цяло деление. След 50 боя е 40%, след 200 48%: колебанията стават по-малки.
- След 1000 боя е 48% и зелената линия показва къде се е установило. Същият въпрос с пет различни семена: при 20 боя отговорите са между 30% и 55%, при 1000 между 48% и 51%.
- Таблицата от 200 боя срещу Пазача: варварин 54%, амазонка 35%, вещица 7%. Лентата е 30 до 60: двама са вътре, вещицата е далеч под нея.
- Настройка: променя се само едно число, животът на вещицата, а всеки ред е със същото семе. При 24 живот е 5% (таблицата даваше 7%: друг генератор, друг късмет).
- 28 живот: 13%, 32: 21%. Лентата ѝ расте с живота, но още е под 30.
- 36 живот: 36%, лентата става зелена: вътре е. Най-малката промяна, която върши работа.
- 40 живот: 51%: и това е вътре, но вещицата вече побеждава Пазача по-често от амазонката (35%). Обходът показва цялата крива, изборът е твой.
- Проверка на избора, 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|depthwarden|Пазач на дълбините|Warden of the Depths|Wächter der Tiefe|Guardián abisal|W|red|120|5|4|3|10|5|9|blunt|150|50|warden|-|-|bleed,poison|warden|guard|4Животът му е числото 120. Ако го смениш на 100 и пуснеш същата симулация, Пазачът се побеждава в 60%, 70% и 72% вместо в 44%, 58% и 54%. Двайсет живота по-малко правят шефа лесен за всички. Проверката на сайта пуска и този вариант на JavaScript, затова числата тук са пуснати, не измислени.
По-точно, за любопитните
Симулацията е част от самата игра, защото иска достъп до вътрешностите: Battle и Enemy. Тя не е външен скрипт, който чете екрана. Затова и основната проверка на сайта (следващата карта) я пуска на четирите езика със семе 7 и 200 боя и сравнява с един файл.
Съзнателно проста е и една друга подробност: симулираният герой взима най-ниската стъпка от дървото на уменията. Истински играч ще избере по-умно, така че таблицата казва колко е труден Пазачът за един прост бот, а не за най-добрия играч.
Идеята
Тест със записано минаване
Как се знае, че промяната на едно число не е счупила нещо друго? Играта има тест с записано минаване (golden test, snapshot test). Файлът playthrough.txt е 230 команди, по една на ред, от създаването на героя до победата над Пазача. Първите четири:
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-loop | main game | 353 |
| screen-stack | screen screen-stack | 92 |
| screen-results | target-screen overlays | 465 |
| console-render | buffer terminal hud | 168 |
| map-fov | level fov map-load map-screen | 500 |
| entities | entity | 234 |
| combat | combat-order attack battle-screen | 381 |
| wounds-effects | effects bleeding | 198 |
| actions | actions replay replay-screen | 234 |
| ai | pathfind ai-map ai-battle | 216 |
| items-loot | items-data inventory equip loot shop | 267 |
| quests-events | xp-levelup event-bus quests dialog dialogs-data skills-data | 296 |
| save-load | save load | 311 |
| arcanum-final | simulate node | 137 |
Всичко е около 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 Arcanumcd Arcanum# смени Program.cs с Arcanum.csdotnet runС --simulate=1000 печата таблицата на баланса вместо да се играе, с --seed=42 светът е един и същ, с --lang=en играта е на английски (има и de и es), с --plain няма цветове. Записите са в папка saves до играта.
Идеята
Как да я продължиш
Играта е направена така, че повечето неща да се добавят, без да се пипа цикълът. Няколко идеи, подредени от най-малката към най-голямата, с мястото в кода, откъдето да започнеш:
- Нов враг. Един ред в таблицата на враговете (
enemies-data), знак и цвят, място на картата (maps-data). После симулацията ти казва дали е прекалено лесен. Нищо в кода не се пипа. - Нов клас. Ред в
CLASSES_DATA, умения вskills-dataи текстове на четирите езика. Симулацията показва колоната на новия клас до другите три. Настрой я с обхода от този урок, докато влезе в лентата. - Нов куест. Диалог в
dialogs-dataи абониране към събитията (quests): мини бележка „донеси руда“ е най-краткият пример в играта. - Нов ефект. Замръзване или забавяне в
effects: избери правилото за натрупване (удължава ли се, събира ли се) така, както са избрани кървенето и отровата. - По-умен враг. Заменяш търсенето в ширина с A* или с поле на разстоянията (виж „По-точно“ в урока за изкуствения интелект) или дай на вълците тактика в глутница.
- Друг изглед.
Terminalе интерфейс с два вида; трети може да рисува вcanvasили в прозорец, без екраните да знаят. Входът и изходът са отделени (io) точно за това. - Език. Текстовете са таблици по езици: добавяш пети език с още едно копие на таблицата с преводи; ако ключ липсва, играта спира с „missing text“, а не го пропуска тихо.
- Нов порт. Играта е на четири езика с един изход. Петият (Rust, Go, Kotlin) се проверява със същия
playthrough.out: докато не е байт по байт същият, портът не е готов. - Режим на ден. Семе от датата, и всички играят един и същ свят; повторението на боя (урокът за командите) вече записва ходовете.
Каквото и да направиш, първо пусни теста със записано минаване, после променяй. Ако падне, прочети първата разлика. Това е целият урок за баланса и за поддръжката на игра в едно.
Това е краят на модула. Започнахме с въпроса „как е устроена игра“ и стигнахме до цяла игра на четири езика с кръв, бой по бързина, врагове, които мислят, запис, който се отказва, ако е развален, и инструмент, който казва дали е добра. Останалото е на теб.
Пробвай
Предвиди изхода
Функцията 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));И процентът, и средните ходове са цяло деление надолу. 1 победа от 3 е 33% (не 33,3 и не 34), 2 от 3 е 66%, 3 от 3 е 100%. Ходовете са в десети: 7 хода за 3 боя е 70 / 3 = 23 десети, т.е. 2.3; 10 хода е 3.3; 9 хода е 90 / 3 = 30, т.е. 3.0. Вторият отговор закръгля (67%), третият дава средните ходове като цяло число.
Пробвай
Намери грешката
Колега иска да направи симулацията „по-чиста“ и дава на всеки бой нов генератор със същото семе. Таблицата вече не прилича на нищо:
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%, а ходовете са цели числа. Кой ред е виновен?
Избери реда с грешката.
new Rng(seed) вътре в цикъла започва поредицата от начало във всеки бой. Така всички 200 боя са един и същ бой, повторен 200 пъти: резултатът е 0% или 100% и средните ходове са цели числа. Генераторът трябва да се създаде един път преди цикъла и да се подава на всеки бой (rng в duels). Семето дава повторяемост, но не бива да се подава наново на всяко хвърляне.
По-точно
По-точно: шум, ботове и записи
Колко е грешката. За процент 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=да се четат от аргументите на програмата, както го прави играта.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Методи на обекта · static: общото за всички · Списъци · record, switch и шаблони
- Задачи по темата: Хрониките на Арканум