Тесты, рефакторинг и влитие WASM
Темпом разработки по прежнему не могу похвастаться, но некоторые обновления за месяц все-таки случились.
Тесты.
Как писал в прошлой заметке, проект дорос до размера, когда польза от тестов перевешивает геморрой их написания-поддержки.
Немного цитат старых обсуждений про тесты из чатов для контекста
@ozkriff:
тем временем, я ломаю себе и другим голову вопросом “как писать тесты логики для пошаговой игры со случайностями?” - https://twitter.com/ozkriff/status/1085488884799160320 (и там ниже обсуждение небольшое)
Пока выходит, что самый адекватный способ, если без наворачивания очень сложной инфраструктуры с отдельными мокаемыми правилами, это просто создать специальные тестовые объекты с нереалистичными характеристиками, так что бы они всегда какой-то определенный тест проходили (например, боец с 999 точностью, который абсолютно всегда попадает). Грязноватое решение, но альтернативы как-то еще хуже.
Есть бесящее ощущение, что я что-то очевидное из вида упускаю, только вот без понятия что.
@Nick Linker
Чтобы сразу откинуть банальщину: у тебя все рандомы с явными сидами, и эти сиды ты записываешь в файл сохранения и они являются частью game state, правильно я понимаю?
У меня (пока) вообще сохранений нет. Я человек простой, рандом сейчас из thread_rng() беру.
@ozkriff:
Если ты о варианте “сохранять зерно” в тестах, то мне он не нравится тем, что вытсавляет наружу часть кишок логики и потом, например, насмерть затруднит добавление какого-то нового правила с рандомом, потому что все тесты разом сломает непредсказуемым образом из-за сдвига последовательности псевдослучайных чисел.
Или ты о чем-то другом?
Чел (и до этого другие) предлагает вероятностные тесты (если результат работы сценария хотя бы столько-то раз приведет к конечным состояниям. удовлетворяющим критериям, то все ок), но я такое могу стерпеть только в очень вспомогательных тестах, слишком уж грязное какое-то решения для основного покрытия логики.
@Nick Linker:
я о недетерминизме, который возникает из-за рандомов, мультипоточности и неявно при IO. Если тебе удалось эффективно и полностью избавиться от недетерминизма в юнит тестах, то ты делаешь всё правильно и беспокоиться не о чем.
Моя игруля конечно хелловорлд по сравнению с Zemeroth, но там тоже рандомы и чтобы сделать воспроизводимые юнит тесты нужно эти рандомы делать явными. Пример куда можно посмотреть, это скажем сюда:
https://github.com/nlinker/xcg/blob/master/tests/model.rs
Тебя может заинтересовать test_run_match_with_reordering
@Alex Zhukovsky: А что конкретно ты тестировать хочешь?
@ozkriff: какие-нибудь сценарии, типа “стоит отравленный демон с единицей здоровья и броней, мы кидаем рядом с ним бомбу, которая взрывом его в огонь толкнет. ожидается, что яд перед смертью у него пройдет, а умрет он от огня в начале своего хода”
даже в таком не слишком сложном сценарии можно ненароком сломать какую-то логику во время рефакторингов (или добавления новых правил-механик) и без автотестов совсем не факт что во время быстрых плейтестов это заметишь
@XX: Почему же? По-моему это нормальное решение: вероятностная логика покрывается статистическими тестами )
@Nlinker: Это имело бы смысл, когда у тебя хардварный RNG, и всё, что ты можешь - это тягать из него значения. Но здесь ведь есть seed!
@ozkriff: Ну, неряшливое какое-то решение, хочу что б все точно было несмотря на случайности.
Плюс, у меня глаз подергивается и флешбеки нехорошие начинаются, когда я представляю себе “а чего это тревис красный статус показывает? ааа, там тест тот проблемный грохнулся, где я коэффеициент не могу подобрать, надо просто перезапустить тесты.”
Mike Lubinets:
А если замокать генератор случайных чисел, добавив сигнатуры сущностей в метод получения числа?
Можно будет его перегружать в тестах и описывать правилами какой именно output должен следовать из конкретной сигнатуры.
Типа вектор (signature, i32), соответствующий кодичеству запросов рандома их тех сущностей, рандом которых ты хочешь контролировать.
Если сигнатура следующего запроса не совпала с [0], пишем ворнинг и возвращаем обычный thread_rng().
Так можно будет и стейт в тестах контролировать и добавлять новые вызовы без заморочек с подбором сида после каждого коммита
@nlinker: тогда проще сделать n генераторов, каждый для отдельной сущности.
@mike:
Либо так, да, но будет жирно по генерикам, если генератор в каждую сущность пихать типом-параметром
Можно в принципе матчиться по get_random(self as &Any) в качестве альтернативы, и иметь возможность зардкодить последовательность генерируемых чисел или сид, если такой вариант подходит, если не глобально, а для отдельных сущностей.
Кстати, а как можно узнать сид для заданной последовательности?)
@nlinker: Если это не криптостойкий rng, то брутфорсом каким-нибудь. Но это сложновато.
Главная беда с тестами Земерота - что делать со случайностью? Выше накидано немного мыслей, но в целом подходы сводятся к:
- запоминать глобальное зерно. просто, но тесты все время будут ломаться от малейшего рефакторинга;
- сильно переработать архитектуру, что бы можно было подменять реализации игровых правил на неслучайные. требует очень сильного усложнения архитектуры;
- хранить отдельные зерна для разных подсистем/типов/правил;
- использовать специальновые тестовые объекты;
Третий вариант, возможно, не так и плох, но мне все еще немного натянуть его на реальный код. Зато последний вариант выглядит просто и, в целом, всем хорош: тестируется именно реальный код, изменений-дополнений минимум нужно, рефакторинги неплохо должен переживать.
Пока выходит, что самый адекватный способ … это просто создать специальные тестовые объекты с нереалистичными характеристиками, так что бы они всегда какой-то определенный тест проходили (например, боец с 999 точностью, который абсолютно всегда попадает).
Получившиеся тесты устроены просто:
- объявляем прототипы;
- объявляем сценарий при помощи этих прототипов;
- создаем отладочное игровое состояние;
- подаем на вход команды;
- на выходе сверяем с ожидаемыми события (+ мгновенные/длительные эффекты и отложенные вызовы способностей).
Простейший тест выглядит как-то так:
#[test]
fn basic_move() {
let prototypes = prototypes(&[
("mover", [component_agent_move_basic()].to_vec()),
("dull", [component_agent_dull()].to_vec()),
]);
let scenario = scenario::default()
.object(P0, "mover", PosHex { q: 0, r: 0 })
.object(P1, "dull", PosHex { q: 0, r: 2 });
let mut state = debug_state(prototypes, scenario);
let path = Path::new(vec![PosHex { q: 0, r: 0 }, PosHex { q: 0, r: 1 }]);
exec_and_check(
&mut state,
command::MoveTo {
id: ObjId(0),
path: path.clone(),
},
&[Event {
active_event: event::MoveTo {
id: ObjId(0),
path,
cost: Moves(1),
}
.into(),
actor_ids: vec![ObjId(0)],
instant_effects: Vec::new(),
timed_effects: Vec::new(),
scheduled_abilities: Vec::new(),
}],
);
}
Список прототипов и сценарий для каждого теста создаются заново. Это занимает немного больше места, чем переиспользование уже готовых прототипов и сценариев, зато так (по задумке) за раз тестируется минимальное количество движущихся частей, за счет чего тесты становятся более точечными (если тест падает, проще понять что пошло не так) и надежными (меньше вероятность, что левое изменение поломает тест).
Вражеский “тупой” агент в этом сценарии нужен просто что бы игра тут же не завершилась.
Методы для декларативного построения сценария из кода подцепляются через дубовый расширяющий типаж:
trait ScenarioConstructor {
fn object(self, player_id: PlayerId, object_name: &str, pos: PosHex) -> Self;
fn object_without_owner(self, object_name: &str, pos: PosHex) -> Self;
}
Саму структуру сценария пришлось переработать, потому что раньше сценарии были обязательно случайными (например, нельзя было точно указать позицию объекта, только примерную область), а в тестах как раз нужна определенность. Добавил полей и всяких проверок.
Кстати, в первом PR’е накосячил с переработанными сценариями - #447 scenario::check: Fix invalid Error::NoPlayerAgents - тесты работали, а вот настоящий бой из интерфейса запустить не выходило, протупил. Лишнее напоминание, что полностью на тесты полагаться не стоит.
Вызов debug_state
прячет внутри взведение страховочного set_deterministic_mode
флажка, задача которого проста: паниковать при попадании потока управления в неконтролируемый рандом, например когда шанс попадания не меньше нуля и не выше максимума:
fn try_attack(state: &State, attacker_id: ObjId, target_id: ObjId) -> Option<Effect> {
...
if state.deterministic_mode() {
// I want to be sure that I either will totally miss
// or that I'll surely hit the target at a full force.
let sure_miss = k_min < 0;
let sure_hit = k_min > 10;
assert!(
sure_miss || sure_hit,
"Hit isn't determined: {:?}",
(k_min, k_max)
);
}
Пришлось заменить все HashMap<ObjId, Vec<X>>
в событиях на Vec<(ObjId, Vec<X>)>
ради сохранения порядка событий и эффектов, иначе тесты мгновенно взрывались.
В процессе выбора формата описания тестов споткнулся о внезапные кривости/проблемы стандартного форматирования. Отчасти из-за этого бага форматирования я не стал, например, использовать билдер для структур Event
+ тестам размазано много лишних полей с пустыми векторами (а Default’ом их не убрать, потому что там первое поле с ActiveEvent
должно быть осмысленное).
И да, реальные тесты можно посмотреть тут: src/core/battle/tests.rs.
мысль на будущее: В текущей реализации тестов мне не очень нравится, что я завязываюсь на конкретные захардкоженный ID объектов, потому что это добавляет тестам хрупкости - я ведь могу во вемя переработки кода взять и во время выполнения какого-то действия вставить код создания нового вспомогательного объекта и тогда вся последующая цепочка айдишников навернется. Думаю, надо относиться к конкретной цифре айдишника как к особенности реализации и получать ее динамически через метод в духе:
fn get_id(state: &GameState, pos: PosHex, metatype: &ObjType) -> ObjId { ... }
Рефакторинг
После написания вышеописанных базовых тестов я наконец решился на рефакторинг. В чем его суть?
По задумке, игрок отправляет команды, которые проверяются на адекватность и “выполняются”, генерируя поток событий и вот уже события изменяют игровое состояние. Проблема была в том, что я из-за лени и невнимательности в нескольких местах обработки команд менял игровое состояние напрямую, в обход системы событий. Это приводило к невозможности выразить некоторые новые запланированные способности, так что рефакторинг был неизбежен.
Несанкционированные места прямого изменения состояния были найдены и переработаны:
В целом рефакторинг прошел вполне гладко, спасибо тестам. Правда, немного я все равно умудрился накосячить - #449 “regression: bomb_push is broken” - у меня не было теста на поведение мгновенно взрывающихся отталкивающих бомб, так что их надо будет сейчас починить.
WebAssembly порт влит в мастер
Веб версия игры наконец влита в мастер! До эттого руки наконец-то тоже дошли.
Не обошлось без нескольких #[cfg(wasm)] и крошечного шелл скрипта, прячущего запихивание ресурсов игры в static, но в остальном все довольно гладко.
#[cfg(not(target_arch = "wasm32"))]
extern crate ggez;
#[cfg(target_arch = "wasm32")]
extern crate good_web_game as ggez;
main у wasm’а тоже отдельный:
#[cfg(target_arch = "wasm32")]
fn main() -> GameResult {
ggez::start(
conf::Conf {
cache: conf::Cache::Index,
loading: conf::Loading::Embedded,
..Default::default()
},
|mut context| {
let state = MainState::new(&mut context).unwrap();
event::run(context, state)
},
)
}
Шелл скриптик для складывания ресурсов в static и генерации их списка выглядит вот так:
$ cat utils/wasm/build.sh
#!/bin/sh
cp -r assets static
cp utils/wasm/index.html static
ls static > static/index.txt
cargo web build
И еще пришлось обновить good-web-game под новым GGEZ, но там просто надо было заменить cgmath типы на mint.
Amit
Внезапно увидел, что Земерот попал к Амиту на страничку с реализациями гексо-математики, миленько: https://www.redblobgames.com/grids/hexagons/implementation.html
Что дальше?
Дальнеший план действий: перевожу журнал на Zola, срезаю 0.5 версию Земерота и пишу заметку в журнал об этом. Надо когда-то выпустить уже новую версию, а блокеров особых уже все равно не осталось.
Я мимокрокодил, можешь ещё попробовать quick_error. Он даёт макросы для тривиальных реализаций description, display, from, into, с возможностью перегрузки. При этом это обычные enum, всё очень примитивно.
Не, quick_error не хочу, у меня сложные отношения с макросами и для такого кода я сильно за процедурные атрибуты.