Цялата игра и балансът
Накратко: Балансът се мери със симулация: хиляди боеве със семе, процент победи за всяка двойка. Числата се настройват едно по едно с обход при същото семе, а тест със записано минаване на цялата игра хваща всяка промяна в поведението.
Проблем
Добра ли е играта?
Играта е готова. Картата се открива, боят върви по бързина, раните кървят, враговете гонят, записът се зарежда. Остава въпросът, на който кодът не отговаря: добра ли е? Може ли вещицата да победи Пазача на дълбините, или варваринът го бие без усилие? Ако играеш ти, ще изиграеш десетина боя и ще имаш мнение. Но десет боя са шум: в този урок един и същ въпрос („варварин срещу Пазача“) дава 30% при едно семе и 55% при друго, когато боевете са само 20.
Трябва начин да се изиграят хиляди боя за секунди, всеки път по един и същи начин, без да се рисува нищо. Това е симулация: боевете се пускат без екран, изкуствен интелект срещу изкуствен интелект, а резултатът е таблица от проценти. С нея се търсят числата, които играта още не е получила: колко живот да има Пазачът, за да е труден, но победим.
Урокът го прави два пъти. Първо на малка играчка, за да се види всяка стъпка и да я пуснеш на четирите езика: боят, таблицата, шумът, настройката и тестът със запис. После на истинската игра, която вече има --simulate. Накрая стоят цялата игра, за да я играеш и изтеглиш, и идеи как да я продължиш.
Идеята
Бой без екран
Правилата на боя в играта не знаят за екрана: екранът само ги вика и рисува резултата (урокът за стека от екрани). Затова същите правила могат да се пуснат и без екран. Играчката по-долу е най-малкото такова нещо: тримата героя срещу четирима врага, всеки с четири числа (живот, точност, най-малка и най-голяма щета). Няма бързина, критични удари и ефекти: тези са в бойната система, а тук е нужна само формата.
Първо генераторът. В играта е mulberry32 от първия урок; тук е още по-малък (на Парк и Милър), за да се събере, но върши същото: същото семе, същите числа.
class Rng { constructor(seed) { this.state = seed; }
next(n) { this.state = (this.state * 48271) % 2147483647; return this.state % n; }}Най-голямото произведение е около 10¹⁴, под 2⁵³, затова и JavaScript смята точно, без нищо допълнително. Остатъкът % n дава число от 0 до n − 1.
Ударът и боят:
// Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 99.function strike(rng, attacker) { if (rng.next(100) < attacker.hit) { return attacker.min + rng.next(attacker.max - attacker.min + 1); } return 0;}
// Боят: героят удря пръв. Свършва, когато някой остане без живот.function fight(rng, hero, enemy) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return { win: true, turns }; heroLife -= strike(rng, enemy); if (heroLife <= 0) return { win: false, turns }; }}Ударът хвърля число от 0 до 99. Ако е по-малко от точността (hit), това е попадение и щетата е случайно число между най-малката и най-голямата. Иначе е пропуск. Боят дава ход на героя, после на врага, докато някой остане без живот. Връща победа или загуба и колко хода е направил героят.
// Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 99.
function strike(rng, 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% по-точен от написаното. Такава грешка не се вижда в нито един бой, но се вижда в таблицата.
Идеята
Таблицата на баланса
Един бой не казва нищо. Затова боевете се пускат много пъти с общ генератор: победите и ходовете на героя се събират.
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
// Много боеве с един общ генератор: победите и ходовете на героя.function duels(rng, hero, enemy, fights) { let wins = 0; let turns = 0; for (let i = 0; i < fights; i++) { const result = fight(rng, hero, enemy); if (result.win) wins++; turns += result.turns; } return { wins, turns };}Процентът е цяло деление (победи × 100 ÷ боеве, надолу), а средните ходове са в десети: turns * 10 / fights, после X.Y. Така няма дробни числа и изходът е еднакъв на четирите езика. Таблицата е simulate: редове са враговете, колони са героите.
// Таблицата: редове са враговете, колони са героите.function simulate(heroes, enemies, fights, seed) { const rng = new Rng(seed); let header = "Враг".padEnd(10); for (const hero of heroes) header += hero.name.padStart(12); console.log(header); const totals = heroes.map(() => 0); for (const enemy of enemies) { let line = enemy.name.padEnd(10); for (let h = 0; h < heroes.length; h++) { const { wins, turns } = duels(rng, heroes[h], enemy, fights); totals[h] += percent(wins, fights); line += cell(wins, fights, turns).padStart(12); } console.log(line); } let average = "Средно".padEnd(10); for (const total of totals) average += (Math.floor(total / enemies.length) + "%").padStart(12); console.log(average);}const heroes = [ { name: "Варварин", hp: 40, hit: 70, min: 4, max: 9 }, { name: "Амазонка", hp: 40, hit: 80, min: 3, max: 7 }, { name: "Вещица", hp: 24, hit: 85, min: 3, max: 8 },];const enemies = [ { name: "Плъх", hp: 9, hit: 60, min: 1, max: 3 }, { name: "Вълк", hp: 18, hit: 70, min: 2, max: 5 }, { name: "Голем", hp: 40, hit: 60, min: 3, max: 6 }, { name: "Пазач", hp: 54, hit: 70, min: 3, max: 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.function strike(rng, attacker) { if (rng.next(100) < attacker.hit) { return attacker.min + rng.next(attacker.max - attacker.min + 1); } return 0;}
// Боят: героят удря пръв. Свършва, когато някой остане без живот.function fight(rng, hero, enemy) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return { win: true, turns }; heroLife -= strike(rng, enemy); if (heroLife <= 0) return { win: false, turns }; }}
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
// Много боеве с един общ генератор: победите и ходовете на героя.function duels(rng, hero, enemy, fights) { let wins = 0; let turns = 0; for (let i = 0; i < fights; i++) { const result = fight(rng, hero, enemy); if (result.win) wins++; turns += result.turns; } return { wins, turns };}
// Процентът победи при нов генератор от даденото семе.function winRate(hero, enemy, fights, seed) { const { wins } = duels(new Rng(seed), hero, enemy, fights); return percent(wins, fights);}
// Лентата на баланса: Пазачът се побеждава в 30 до 60 от 100 боя.function inBand(rate) { return rate >= 30 && rate <= 60;}
// Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.function sweep(warden, fights, seed) { for (let life = 24; life <= 40; life += 4) { const witch = { name: "Вещица", hp: life, hit: 85, min: 3, max: 8 }; const rate = winRate(witch, warden, fights, seed); console.log(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 боя.
Кодът до сцената е боят, цикълът на боевете и обходът от програмите на урока. На всяка стъпка светят редовете, които работят в този момент.
Идеята
Шумът: същият въпрос, друго семе
Процентът от симулация е оценка, не истина. Всяко семе дава друга поредица от хвърляния, а с нея и друго число. Колко се различават отговорите? Ето един и същ въпрос (варварин срещу Пазача) с пет семена и два размера:
// Процентът победи при нов генератор от даденото семе.function winRate(hero, enemy, fights, seed) { const { wins } = duels(new Rng(seed), hero, enemy, fights); return percent(wins, fights);}const barbarian = { name: "Варварин", hp: 40, hit: 70, min: 4, max: 9 };const warden = { name: "Пазач", hp: 54, hit: 70, min: 3, max: 7 };console.log(barbarian.name + " срещу " + warden.name + ":");for (const fights of [20, 1000]) { let line = fights + " боя:"; for (let seed = 1; seed <= 5; seed++) { line += " " + winRate(barbarian, warden, fights, seed) + "%"; } console.log(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 боя.function inBand(rate) { return rate >= 30 && rate <= 60;}
// Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.function sweep(warden, fights, seed) { for (let life = 24; life <= 40; life += 4) { const witch = { name: "Вещица", hp: life, hit: 85, min: 3, max: 8 }; const rate = winRate(witch, warden, fights, seed); console.log(witch.name + ", живот " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : "")); }}const warden = { name: "Пазач", hp: 54, hit: 70, min: 3, max: 7 };sweep(warden, 200, 7);const tuned = { name: "Вещица", hp: 36, hit: 85, min: 3, max: 8 };const tunedRate = winRate(tuned, warden, 1000, 7);console.log(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 е същото нещо, но върху истинската игра:
// Балансът: стотици боеве без екран, изкуствен интелект срещу изкуствен интелект. Героят е с// ниво според дълбочината на врага; таблицата казва процента победи и средните ходове.const SIM_LEVELS = [1, 1, 3, 5, 7];
// Симулираният герой учи най-ниската стъпка от дървото (при равенство: първия клон).function simSkillChoice(hero) { let best = ''; for (const skillId of learnableSkills(hero)) { if (best === '' || SKILLS.get(skillId).tier < SKILLS.get(best).tier) best = skillId; } return best;}
function makeSimHero(game, heroClass, level) { const hero = createHero(game, cap(nm(heroClass)), heroClass.id); for (let next = 2; next <= level; next++) { raiseLevel(hero); const skill = simSkillChoice(hero); if (next % 2 === 0 && skill !== '') hero.skills.push(skill); else if (heroClass.id === 'barbarian') applyLevelChoice(hero, { kind: 'stat', stat: 'strength', amount: 1 }); else if (heroClass.id === 'amazon') applyLevelChoice(hero, { kind: 'stat', stat: 'agility', amount: 1 }); else applyLevelChoice(hero, { 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', Math.floor(level / 2)); return hero;}
function simulateFight(game, heroClass, kind) { game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]); const battle = new Battle(game, [new Enemy(game.newId(), kind)], false); battle.autoHero = true; battle.advance(); return { win: battle.outcome === 'win', turns: battle.heroTurns };}
function simulate(io, fights, seed) { const 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'); let header = pad(t('sim.enemy'), 26) + padLeft(t('sim.level'), 5); for (const heroClass of CLASSES.values()) header += padLeft(cap(nm(heroClass)), 14); io.write(header + '\n'); const totals = []; for (let i = 0; i < CLASSES.size; i++) totals.push(0); let rows = 0; for (const kind of ENEMY_KINDS.values()) { if (kind.depth === 0) continue; rows++; let line = pad(cap(nm(kind)), 26) + padLeft(String(SIM_LEVELS[kind.depth]), 5); let column = 0; for (const heroClass of CLASSES.values()) { let wins = 0; let turns = 0; for (let fight = 0; fight < fights; fight++) { const result = simulateFight(game, heroClass, kind); if (result.win) wins++; turns += result.turns; } const winShare = percent(wins, fights); totals[column] += winShare; column++; const tenths = Math.floor((turns * 10) / fights); line += padLeft(winShare + '% ' + Math.floor(tenths / 10) + '.' + (tenths % 10), 14); } io.write(line + '\n'); } let footer = pad(t('sim.average'), 31); for (const total of totals) footer += padLeft(Math.floor(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) и се сравнява с записа:
// Един ред от дневника на боя.function describe(turn, who, target, damage, left) { if (damage === 0) return turn + ". " + who + " пропуска"; return turn + ". " + who + " удря " + damage + " → " + target + " " + left;}
function fight(rng, hero, enemy, log) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; let damage = strike(rng, hero); enemyLife -= damage; log.push(describe(turns, hero.name, enemy.name, damage, Math.max(0, enemyLife))); if (enemyLife <= 0) return { win: true, turns }; damage = strike(rng, enemy); heroLife -= damage; log.push(describe(turns, enemy.name, hero.name, damage, Math.max(0, heroLife))); if (heroLife <= 0) return { win: false, turns }; }}
// Първият ред, в който двата дневника се различават (0, ако са еднакви).function firstDifference(a, b) { for (let i = 0; i < Math.max(a.length, b.length); i++) { if (i >= a.length || i >= b.length || a[i] !== b[i]) return i + 1; } return 0;}
function lineAt(lines, at) { return at <= lines.length ? lines[at - 1] : "-";}
// Тестът: бой със семе 2027 и сравнение със записаното.function check(label, recorded, hero, wolf) { const log = []; fight(new Rng(2027), hero, wolf, log); const at = firstDifference(recorded, log); if (at === 0) { console.log(label + ": вярно, " + log.length + " реда"); } else { console.log(label + ": ред " + at); console.log(" очаквано: " + lineAt(recorded, at)); console.log(" получено: " + lineAt(log, at)); }}const recorded = [ "1. Варварин удря 6 → Вълк 12", "1. Вълк пропуска", "2. Варварин пропуска", "2. Вълк пропуска", "3. Варварин пропуска", "3. Вълк удря 3 → Варварин 37", "4. Варварин удря 4 → Вълк 8", "4. Вълк удря 2 → Варварин 35", "5. Варварин удря 5 → Вълк 3", "5. Вълк пропуска", "6. Варварин удря 5 → Вълк 0",];const hero = { name: "Варварин", hp: 40, hit: 70, min: 4, max: 9 };check("вълк, най-много щета 5", recorded, hero, { name: "Вълк", hp: 18, hit: 70, min: 2, max: 5 });check("вълк, най-много щета 6", recorded, hero, { name: "Вълк", hp: 18, hit: 70, min: 2, max: 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). Сложи един в празна папка и го пусни:
node arcanum.jsС --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 пише една клетка от таблицата: процент победи и средни ходове. Какво печата програмата?
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
console.log(cell(1, 3, 7));console.log(cell(2, 3, 10));console.log(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%), третият дава средните ходове като цяло число.
Пробвай
Намери грешката
Колега иска да направи симулацията „по-чиста“ и дава на всеки бой нов генератор със същото семе. Таблицата вече не прилича на нищо:
const heroes = [ { name: "Варварин", hp: 40, hit: 70, min: 4, max: 9 }, { name: "Амазонка", hp: 40, hit: 80, min: 3, max: 7 }, { name: "Вещица", hp: 24, hit: 85, min: 3, max: 8 },];const golem = { name: "Голем", hp: 40, hit: 60, min: 3, max: 6 };for (const hero of heroes) { const { wins, turns } = duelsBug(7, hero, golem, 200); console.log(hero.name + ": " + cell(wins, 200, 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=да се четат от аргументите на програмата, както го прави играта.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Конструктори и this · Методи на обекта · Масиви: push, splice и sort · Данни, switch и разглобяване
- Задачи по темата: Хрониките на Арканум