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

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

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

Проблем

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

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

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

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

Идеята

Бой без екран

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

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

class Rng:
def __init__(self, seed):
self.state = seed
def next(self, n):
self.state = self.state * 48271 % 2147483647
return self.state % n

Най-голямото произведение е около 10¹⁴, под 2⁵³, затова и JavaScript смята точно, без нищо допълнително. Остатъкът % n дава число от 0 до n − 1.

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

# Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 99.
def strike(rng, attacker):
if rng.next(100) < attacker.hit:
return attacker.min + rng.next(attacker.max - attacker.min + 1)
return 0
# Боят: героят удря пръв. Свършва, когато някой остане без живот.
def fight(rng, hero, enemy):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
enemy_life -= strike(rng, hero)
if enemy_life <= 0:
return True, turns
hero_life -= strike(rng, enemy)
if hero_life <= 0:
return False, turns

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

# Един удар: 0 при пропуск, иначе щетата. Хвърлянето е от 0 до 99.
def strike(rng, attacker):
    if rng.next(100)  attacker.hit:
        return attacker.min + rng.next(attacker.max - attacker.min + 1)
    return 0

Идеята

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

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

def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
# Много боеве с един общ генератор: победите и ходовете на героя.
def duels(rng, hero, enemy, fights):
wins = 0
turns = 0
for _ in range(fights):
win, hero_turns = fight(rng, hero, enemy)
if win:
wins += 1
turns += hero_turns
return wins, turns

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

# Таблицата: редове са враговете, колони са героите.
def simulate(heroes, enemies, fights, seed):
rng = Rng(seed)
header = "Враг".ljust(10)
for hero in heroes:
header += hero.name.rjust(12)
print(header)
totals = [0] * len(heroes)
for enemy in enemies:
line = enemy.name.ljust(10)
for h in range(len(heroes)):
wins, turns = duels(rng, heroes[h], enemy, fights)
totals[h] += percent(wins, fights)
line += cell(wins, fights, turns).rjust(12)
print(line)
average = "Средно".ljust(10)
for total in totals:
average += (str(total // len(enemies)) + "%").rjust(12)
print(average)
heroes = [
Fighter("Варварин", 40, 70, 4, 9),
Fighter("Амазонка", 40, 80, 3, 7),
Fighter("Вещица", 24, 85, 3, 8),
]
enemies = [
Fighter("Плъх", 9, 60, 1, 3),
Fighter("Вълк", 18, 70, 2, 5),
Fighter("Голем", 40, 60, 3, 6),
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.
def strike(rng, attacker):
if rng.next(100) < attacker.hit:
return attacker.min + rng.next(attacker.max - attacker.min + 1)
return 0
# Боят: героят удря пръв. Свършва, когато някой остане без живот.
def fight(rng, hero, enemy):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
enemy_life -= strike(rng, hero)
if enemy_life <= 0:
return True, turns
hero_life -= strike(rng, enemy)
if hero_life <= 0:
return False, turns
def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
# Много боеве с един общ генератор: победите и ходовете на героя.
def duels(rng, hero, enemy, fights):
wins = 0
turns = 0
for _ in range(fights):
win, hero_turns = fight(rng, hero, enemy)
if win:
wins += 1
turns += hero_turns
return wins, turns
# Процентът победи при нов генератор от даденото семе.
def win_rate(hero, enemy, fights, seed):
wins, _ = duels(Rng(seed), hero, enemy, fights)
return percent(wins, fights)
# Лентата на баланса: Пазачът се побеждава в 30 до 60 от 100 боя.
def in_band(rate):
return 30 <= rate <= 60
# Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.
def sweep(warden, fights, seed):
for life in range(24, 41, 4):
witch = Fighter("Вещица", life, 85, 3, 8)
rate = win_rate(witch, warden, fights, seed)
print(witch.name + ", живот " + str(life) + ": " + str(rate) + "%" + (" ✓" if in_band(rate) else ""))
Стъпка по стъпка, с думи
  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 боя.

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

Идеята

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

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

# Процентът победи при нов генератор от даденото семе.
def win_rate(hero, enemy, fights, seed):
wins, _ = duels(Rng(seed), hero, enemy, fights)
return percent(wins, fights)
barbarian = Fighter("Варварин", 40, 70, 4, 9)
warden = Fighter("Пазач", 54, 70, 3, 7)
print(barbarian.name + " срещу " + warden.name + ":")
for fights in [20, 1000]:
line = str(fights) + " боя:"
for seed in range(1, 6):
line += " " + str(win_rate(barbarian, warden, fights, seed)) + "%"
print(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 боя.
def in_band(rate):
return 30 <= rate <= 60
# Живот на вещицата от 24 до 40: всеки ред е със същото семе, различава се само животът.
def sweep(warden, fights, seed):
for life in range(24, 41, 4):
witch = Fighter("Вещица", life, 85, 3, 8)
rate = win_rate(witch, warden, fights, seed)
print(witch.name + ", живот " + str(life) + ": " + str(rate) + "%" + (" ✓" if in_band(rate) else ""))
warden = Fighter("Пазач", 54, 70, 3, 7)
sweep(warden, 200, 7)
tuned = Fighter("Вещица", 36, 85, 3, 8)
tuned_rate = win_rate(tuned, warden, 1000, 7)
print(tuned.name + ", живот 36, 1000 боя: " + str(tuned_rate) + "%" + (" ✓" if in_band(tuned_rate) else ""))
Изход
Вещица, живот 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 е същото нещо, но върху истинската игра:

# Балансът: стотици боеве без екран, изкуствен интелект срещу изкуствен интелект. Героят е с
# ниво според дълбочината на врага; таблицата казва процента победи и средните ходове.
SIM_LEVELS = [1, 1, 3, 5, 7]
# Симулираният герой учи най-ниската стъпка от дървото (при равенство: първия клон).
def sim_skill_choice(hero):
best = ''
for skill_id in learnable_skills(hero):
if best == '' or SKILLS[skill_id].tier < SKILLS[best].tier:
best = skill_id
return best
def make_sim_hero(game, hero_class, level):
hero = create_hero(game, cap(nm(hero_class)), hero_class.id)
for next_level in range(2, level + 1):
raise_level(hero)
skill = sim_skill_choice(hero)
if next_level % 2 == 0 and skill != '':
hero.skills.append(skill)
elif hero_class.id == 'barbarian':
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='strength', amount=1))
elif hero_class.id == 'amazon':
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='agility', amount=1))
else:
apply_level_choice(hero, SimpleNamespace(kind='stat', stat='maxHp', amount=8))
if level >= 4:
hero.equipment['weapon'] = hero_class.better_weapon
hero.equipment['armor'] = hero_class.better_armor
hero.hp = hero.max_hp()
hero.mana = hero.max_mana()
hero.inventory.add('potion_heal', level // 2)
return hero
def simulate_fight(game, hero_class, kind):
game.hero = make_sim_hero(game, hero_class, SIM_LEVELS[kind.depth])
battle = Battle(game, [Enemy(game.new_id(), kind)], False)
battle.auto_hero = True
battle.advance()
return SimpleNamespace(win=battle.outcome == 'win', turns=battle.hero_turns)
def simulate(io, fights, seed):
game = Game(io, seed)
game.simulation = True
io.write(t('sim.title', {'fights': fights, 'seed': seed}) + '\n')
io.write(t('sim.levels') + '\n\n')
header = pad(t('sim.enemy'), 26) + pad_left(t('sim.level'), 5)
for hero_class in CLASSES.values():
header += pad_left(cap(nm(hero_class)), 14)
io.write(header + '\n')
totals = []
for i in range(len(CLASSES)):
totals.append(0)
rows = 0
for kind in ENEMY_KINDS.values():
if kind.depth == 0:
continue
rows += 1
line = pad(cap(nm(kind)), 26) + pad_left(str(SIM_LEVELS[kind.depth]), 5)
column = 0
for hero_class in CLASSES.values():
wins = 0
turns = 0
for fight in range(fights):
result = simulate_fight(game, hero_class, kind)
if result.win:
wins += 1
turns += result.turns
win_share = percent(wins, fights)
totals[column] += win_share
column += 1
tenths = (turns * 10) // fights
line += pad_left(str(win_share) + '% ' + str(tenths // 10) + '.' + str(tenths % 10), 14)
io.write(line + '\n')
footer = pad(t('sim.average'), 31)
for total in totals:
footer += pad_left(str(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) и се сравнява с записа:

# Един ред от дневника на боя.
def describe(turn, who, target, damage, left):
if damage == 0:
return str(turn) + ". " + who + " пропуска"
return str(turn) + ". " + who + " удря " + str(damage) + " → " + target + " " + str(left)
def fight(rng, hero, enemy, log):
hero_life = hero.hp
enemy_life = enemy.hp
turns = 0
while True:
turns += 1
damage = strike(rng, hero)
enemy_life -= damage
log.append(describe(turns, hero.name, enemy.name, damage, max(0, enemy_life)))
if enemy_life <= 0:
return True, turns
damage = strike(rng, enemy)
hero_life -= damage
log.append(describe(turns, enemy.name, hero.name, damage, max(0, hero_life)))
if hero_life <= 0:
return False, turns
# Първият ред, в който двата дневника се различават (0, ако са еднакви).
def first_difference(a, b):
for i in range(max(len(a), len(b))):
if i >= len(a) or i >= len(b) or a[i] != b[i]:
return i + 1
return 0
def line_at(lines, at):
return lines[at - 1] if at <= len(lines) else "-"
# Тестът: бой със семе 2027 и сравнение със записаното.
def check(label, recorded, hero, wolf):
log = []
fight(Rng(2027), hero, wolf, log)
at = first_difference(recorded, log)
if at == 0:
print(label + ": вярно, " + str(len(log)) + " реда")
else:
print(label + ": ред " + str(at))
print(" очаквано: " + line_at(recorded, at))
print(" получено: " + line_at(log, at))
recorded = [
"1. Варварин удря 6 → Вълк 12",
"1. Вълк пропуска",
"2. Варварин пропуска",
"2. Вълк пропуска",
"3. Варварин пропуска",
"3. Вълк удря 3 → Варварин 37",
"4. Варварин удря 4 → Вълк 8",
"4. Вълк удря 2 → Варварин 35",
"5. Варварин удря 5 → Вълк 3",
"5. Вълк пропуска",
"6. Варварин удря 5 → Вълк 0",
]
hero = Fighter("Варварин", 40, 70, 4, 9)
check("вълк, най-много щета 5", recorded, hero, Fighter("Вълк", 18, 70, 2, 5))
check("вълк, най-много щета 6", recorded, hero, 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). Сложи един в празна папка и го пусни:

Терминал
python arcanum.py

С --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 пише една клетка от таблицата: процент победи и средни ходове. Какво печата програмата?

def percent(wins, fights):
return wins * 100 // fights
def cell(wins, fights, turns):
tenths = turns * 10 // fights
return str(percent(wins, fights)) + "% " + str(tenths // 10) + "." + str(tenths % 10)
print(cell(1, 3, 7))
print(cell(2, 3, 10))
print(cell(3, 3, 9))
Отговор

Пробвай

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

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

heroes = [
Fighter("Варварин", 40, 70, 4, 9),
Fighter("Амазонка", 40, 80, 3, 7),
Fighter("Вещица", 24, 85, 3, 8),
]
golem = Fighter("Голем", 40, 60, 3, 6)
for hero in heroes:
wins, turns = duels_bug(7, hero, golem, 200)
print(hero.name + ": " + cell(wins, 200, 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 за достъпно обучение по програмиране ·