go_games_collection/ARCHITECTURE.md
2026-07-15 18:02:34 +03:00

25 KiB
Raw Permalink Blame History

Архитектура проекта

Коллекция терминальных игр на Go (Bubble Tea + Lipgloss). Этот файл описывает, как устроен код: что за что отвечает, где и как хранятся сохранения, и — отдельным разделом — подробно разбирает экономику и вероятности режима истории (сторимода), самой сложной части проекта.

Общий объём: ~27 000 строк кода, ~18 000 строк тестов, 17 игр.

1. Общая структура файлов

Инфраструктура (общая для всех игр)

Файл За что отвечает
main.go Точка входа — запуск программы Bubble Tea
menu.go Главное меню: выбор языка, реестр игр (gameRegistry), все экраны настройки сложности/вариантов перед началом партии, диспетчеризация состояний верхнего уровня (AppModel.Update/View)
tui.go Общие для всех игр стили (lipgloss), рендер карт (renderCardBox/renderCardBoxHand), wrapText (перенос длинного текста по ширине терминала), тип ColorChoice (общий выбор цвета/стороны для 2-игроцких игр)
i18n.go Механизм переключения языка (SetLanguage/CurrentLang)
translations.go Все строки интерфейса на русском и английском (один общий словарь map[string][2]string, доступ через T(key)/Tf(key, args...))
translations_wayfarer{1..4}.go То же самое, но переводы Странника вынесены в отдельные файлы — их одних около 900 строк текста квестов
rules.go Тексты правил каждой игры (RU/EN), показываются по --help/--help-en и из меню
banner.go ASCII-баннер главного меню и его компактная замена для низких терминалов
card.go, deck.go Общий тип карты/масти/ранга и колода — используются всеми карточными играми
player.go Общий вспомогательный тип игрока для карточных игр
bot.go, bot_heuristics.go Общие для нескольких карточных ботов эвристики и генератор имён ботов (newBotRoster)
meld.go Логика мелдов (сетов/последовательностей), используется Тонком

Каждая игра: три (иногда четыре) файла по своему шаблону

Для игр с ботом принят единый шаблон из трёх файлов:

  • <игра>_game.go — чистое состояние партии и правила: структуры данных, конструктор New...Game(...), методы применения ходов, определение результата. Никакого Bubble Tea, никакого ввода-вывода — это чистая логика, которую тестируют без построения интерфейса.
  • <игра>_bot.go — стратегия бота: type ...Bot struct, DecideAction/Move и подобное. Уровни сложности (Easy/ Medium/Hard, где есть) определены здесь же.
  • <игра>_tui.go — обёртка Bubble Tea: type ...Model struct, Update/View, обработка нажатий клавиш, отрисовка.

Игры без бота (Косынка, Сапёр) обходятся без _bot.go. У игр с сохранением на диске (Странник, Трупные батончики, сторимод) есть ещё и <игра>_save.go.

Игра Файлы
Тонк game.go, bot.go (часть), tui.go — исторически первая игра, поэтому без префикса
Дурак durak_game.go, durak_bot.go, durak_tui.go, durak_deck.go (своя колода на 36 карт)
Блэкджек blackjack_game.go, blackjack_bot.go, blackjack_tui.go
101 oneohone_game.go, oneohone_bot.go, oneohone_tui.go
1000 thousand_game.go, thousand_bot.go, thousand_tui.go
Косынка klondike_game.go, klondike_tui.go (без бота — пасьянс)
Шашки checkers_game.go, checkers_bot.go, checkers_tui.go
Шахматы chess_game.go, chess_bot.go, chess_tui.go
Го go_game.go, go_bot.go, go_tui.go
Сапёр minesweeper_game.go, minesweeper_tui.go (без бота — головоломка)
Крестики-нолики tictactoe_game.go, tictactoe_bot.go, tictactoe_tui.go
Нарды nardy_game.go, nardy_bot.go, nardy_tui.go
Игра Ур ur_game.go, ur_bot.go, ur_tui.go
Сенет senet_game.go, senet_bot.go, senet_tui.go
Покер poker_game.go, poker_bot.go, poker_hand.go (оценка комбинаций), poker_tui.go
Трупные батончики corpsestarch_game.go, corpsestarch_tui.go, corpsestarch_save.go
Странник wayfarer_ship.go (состояние корабля/капитала), wayfarer_galaxy.go (карта звёздной системы), wayfarer_economy.go, wayfarer_combat.go, wayfarer_encounters.go, wayfarer_env.go, wayfarer_quests.go/wayfarer_quest_state.go/wayfarer_quest_loader.go (квесты), wayfarer_tui.go, wayfarer_save.go
Сторимод story_game.go, story_tui.go, story_save.go — разбор ниже, в разделе 3

Странник — самая крупная игра, поэтому единственная, где движок разнесён по смыслу на 9 файлов вместо одного _game.go.

Тесты

Для каждого <файл>.go есть парный <файл>_test.go — тесты лежат рядом с тем, что тестируют, а не в отдельном дереве. Общее число тестов на момент этого файла — около 940.

2. Сохранения

Только три игры коллекции пишут что-либо на диск — у остальных 14 партия начинается с чистого листа при каждом запуске, и никакие настройки (сложность, вариант правил) между запусками не запоминаются.

Игра Файл сохранения Что внутри
Странник wayfarer_save.go WayfarerShip — корабль, груз, капитал, прогресс квестов
Трупные батончики corpsestarch_save.go CorpseStarchGameState — весь прогресс кликера, включая переход на сторону Хаоса
Сторимод story_save.go StoryModeState — см. раздел 3

Путь у всех трёх один и тот же шаблон — системная конфигурационная директория пользователя (os.UserConfigDir() в Go: ~/.config на Linux, ~/Library/Application Support на macOS, %AppData% на Windows) плюс общая поддиректория go-games-collection:

~/.config/go-games-collection/wayfarer_save.json
~/.config/go-games-collection/corpsestarch_save.json
~/.config/go-games-collection/story_save.json

Формат — обычный json.MarshalIndent прямо по Go-структуре, без отдельной описанной схемы: что в структуре, то и в JSON, поля один в один по именам. У Странника и Трупных батончиков при загрузке старого сохранения есть миграция недостающих полей (на случай, если сохранение создано более ранней версией игры, где каких-то полей ещё не было) — у сторимода такой миграции пока нет, он младше и ещё не переживал смену формата при существующих сохранениях.

Каждый из трёх файлов сохранения полностью независим от двух других — сброс прогресса в одной игре не трогает остальные.

3. Режим истории (сторимод) — устройство подробно

3.1. Общая идея

Сторимод — надстройка над обычными играми коллекции: выбираешь сложность один раз в начале, дальше играешь в любые из 15 подключённых игр (все, кроме Странника и Трупных батончиков — см. ниже почему), и победа в каждой начисляет свой уникальный ресурс. Цель — накопить нужное количество каждого ресурса.

Файлы:

  • story_game.go — вся логика: типы ресурсов, начисление, расход, суточные события, показатели выживания. Чистая логика без Bubble Tea, как и <игра>_game.go у обычных игр.
  • story_tui.go — экраны сторимода (хаб, выбор сложности, приветствия, использование ресурсов, скрытая концовка) и реестр подключённых игр (storyGameDefs) с условием победы для каждой.
  • story_save.go — сохранение/загрузка/удаление, тот же шаблон, что у Странника и Батончиков.
  • Диспетчеризация состояний сторимода (какое состояние ведёт в какое) прописана в общем menu.go, наравне с обычным меню.

3.2. Почему только 15 игр из 17

Странник (открытый мир без единого условия "партия окончена") и Трупные батончики (кликер без дискретной победы/поражения) не вписываются в модель "победил — получил ресурс". Обе доступны как обычно в режиме "просто игры", но в сторимоде просто отсутствуют.

3.3. Ресурсы — таблица целиком

Один ресурс на игру, идентификатор — StoryResourceID (перечисление в story_game.go), в этом же порядке показывается в хабе:

Игра Ресурс Цель Условие начисления
Тонк Бутылка воды 100 3+ раунда; баланс не ниже, чем у соперников, при выходе
Дурак Патроны для ружья 100 3 партии подряд без "дурака"
Шашки Банка тушёнки 100 Победа
Нарды Сигарета 100 Победа
Блэкджек Самогон 100 3+ раунда; баланс выше стартового при выходе
Шахматы Аптечка 100 Победа
Сапёр Антидот 100 3 партии подряд без взрыва
Го Фильтр для воздуха 100 Победа
Игра Ур Спички 100 Победа
Сенет Верёвка 100 Победа
101 Батарейки 100 Победа во всём матче (не в отдельном раунде)
1000 Изолента 100 3+ раздачи; первое место по очкам при выходе
Косынка Антидепрессант 100 3 победы за один день (не подряд, не общий счётчик)
Крестики-нолики Кусок мыла 10 Победа или ничья
Покер Золотые зубы 100 Победа во всём турнире

Целевые числа — отдельные именованные константы (StoryTargetTonk, StoryTargetDurak, ...), а не одно общее число, специально для того, чтобы их было легко подправить по отдельности.

Условия победы реализованы как функции storyOutcome<Игра>(m tea.Model) (finished, won bool) в story_tui.go — каждая проверяет конкретное поле результата СВОЕЙ игры (Result.Winner, RoundsPlayed, и т.п.). Три игры (Тонк, Блэкджек, 1000) потребовали добавить в их собственные _game.go счётчик RoundsPlayed/HandNumber — минимальное дополнение, не влияющее на обычную игру вне сторимода. Дурак и Сапёр хранят "серию без поражения" прямо в своей Model (storyNonFoolStreak/storyWinStreak) — поле, которого не существует для обычного режима "просто игры".

3.4. Начисление ресурса — суточный лимит

Победа сама по себе не гарантирует ресурс: AwardResourcestory_game.go) пропускает начисление, если сегодня уже случилось StoryDailyGrantsLimit (= 3) начислений — играть можно сколько угодно, просто пятая-шестая победа за день ничего не даст. У Косынки это накладывается НА её собственное правило "3 победы за день" — сначала должно сработать правило Косынки, и только потом на этот факт начисления натягивается общий суточный лимит.

Счётчик DailyGrantsUsed (как и KlondikeWinsToday) сбрасывается в Tick() при переходе на новые виртуальные сутки.

3.5. Запирание в сейф — и что это НЕ значит

Как только Resources[id] >= Target, ресурс помечается Locked[id] = true. Это НЕ блокирует ни саму игру (можно продолжать играть и дальше копить тот же ресурс без верхнего предела), ни расход — единственный эффект: пока ресурс заперт, суточные события, способные его как-то уменьшить, его не трогают (сейчас таких событий, уменьшающих СЧЁТ ресурса, нет — есть только расход самим игроком, spendResource, который снимает пометку Locked, если после траты осталось меньше цели).

3.6. Этап знакомства (онбординг)

Новая партия сторимода не сразу открывает все 15 игр. Три этапа (StoryOnboardingStage в story_game.go, монотонное продвижение только вперёд — не завязано на текущие значения ресурсов, чтобы суточные события их не откатили):

  1. Только крестики-нолики. Числа ресурсов и весь остальной антураж лагеря скрыты.
  2. + Шашки — после storyStartingAmount + 3 очков ресурса крестиков-ноликов (то есть 3 реальных победы/ничьих сверх стартовых трёх).
  3. Обычный режим — после storyStartingAmount + 1 очков ресурса шашек (1 победа). В момент перехода на этот этап показывается одноразовое приветствие от ИИ убежища (stateStoryWelcome, печатается по слову каждые 250мс) — и только оно, никогда больше не повторяется для этой партии.

3.7. Показатели выживания

Три процентных показателя (0100), видны в лагере только после завершения знакомства:

  • Стресс (Stress) — не опускается ниже StoryStressFloor (20) сам по себе, но антидепрессант может увести его вплоть до 0, с последующим часовым восстановлением обратно к 20 (см. 3.8).
  • Отравление (Poisoning).
  • Токсин в воздухе (ToxinLevel).

Ни один из трёх не меняется от одного факта течения времени — только по конкретным событиям (см. 3.9) и по расходу ресурсов (см. 3.8).

3.8. Расходуемые ресурсы — экран [u] в лагере

Четыре ресурса имеют не только "цель для сейфа", но и практический расходуемый эффект (storyUseItemDefs в story_tui.go):

Ресурс Эффект (метод в story_game.go)
Антидепрессант (Косынка) SpendAntidepressant — стресс 20%, может уйти ниже пола вплоть до 0; если ушёл ниже пола, запускает часовое восстановление обратно к 20% (ApplyStressRecovery, линейная интерполяция, как и очистка воздуха ниже)
Антидот (Сапёр) SpendAntidote — отравление 20%, не ниже 0
Фильтр для воздуха (Го) SpendAirFilter — отмечает фильтр использованным сегодня (FilterUsedToday), что на сегодняшние сутки снимает риск выброса нейротоксина (см. 3.9) и при обработке суток сбрасывает счётчик дней без замены
Батарейки (101) SpendBattery — запускает очистку воздуха: токсин плавно идёт к 0 в течение часа (ApplyAirPurification)

Оба "часовых" эффекта (восстановление стресса, очистка воздуха) устроены одинаково: запоминается начальное значение и момент запуска, а при каждом входе в лагерь (enterStoryHub/ enterStoryHubFromModeSelect) линейно интерполируется текущее значение по прошедшему времени, вплоть до полного завершения через час.

Трата ресурса (spendResource) снимает пометку Locked, если после списания остаток опустился ниже цели — расход реально расходует накопленное, а не просто "ест" бесплатный излишек сверху.

3.9. Суточные события — вероятности

Оба события разыгрываются в processDayEvents (story_game.go), по одному разу за каждые виртуальные сутки, обработанные в Tick (если игрок не заходил несколько дней подряд, каждый день розыгрывается отдельно, а не одним махом).

Выброс нейротоксина — зависит от того, сколько суток подряд фильтр для воздуха НЕ менялся (DaysSinceFilterUsed, storyNeurotoxinProbability):

Суток без замены фильтра Вероятность выброса за эти сутки
0 (фильтр менялся сегодня) 0%
1 50%
2 75%
3 и более 100% (гарантированно)

Если выброс произошёл — токсин в воздухе растёт на случайное значение от 10% до 70% (равномерно, 10 + rnd.Intn(61)), с насыщением на 100%. Счётчик дней без фильтра НЕ сбрасывается самим фактом выброса — только реальной заменой фильтра, так что при долгом пренебрежении выбросы могут случаться несколько дней подряд один за другим.

Стук в дверь — не зависит ни от какого ресурса, только от того, сколько суток его не было (DaysSinceDoorKnock, storyDoorKnockProbability):

Суток без стука Вероятность за эти сутки
0 0%
1 10%
2 50%
3 и более 100%

Если событие происходит — стресс +20% (потолок 100%), счётчик сбрасывается. Этот прирост стресса НЕ уходит сам по себе — снять его можно только антидепрессантом.

Оба события показываются игроку только при входе через меню режимов (enterStoryHubFromModeSelect) — не при обычном возврате из партии (enterStoryHub), чтобы не прерывать уже идущую игровую сессию всплывающими уведомлениями после каждой отдельной партии. Флаги PendingNeurotoxinAlert/PendingDoorKnockAlert копятся до следующего входа через меню и показываются по очереди, после приветствия дня.

3.10. Приветствие дня

Тоже только при входе через меню режимов, одно из трёх (storyGreetingForDays, по числу суток с прошлого такого входа):

Суток с прошлого входа Приветствие
0 (тот же день) «Снова решил поиграть? Смотри, чтобы не началась зависимость.»
1 «Ты проснулся! Возьми свой дневной паёк.»
2 и более «Я думала с тобой что-то случилось!..»

Плюс при каждом обычном возврате из партии (не через меню) в лагере показывается одна случайная короткая фраза-наблюдение из пула 12 штук (storyPostGameFlavorKeys) — про пульс, дыхание, зрачки и т.п., от лица ИИ убежища.

3.11. Скрытая концовка

Пункт «Сбежать из бункера» появляется в конце списка игр в лагере, когда одновременно выполнены все три условия (storyEscapeUnlocked):

  • Кусок мыла (Крестики-нолики) ≥ 50
  • Верёвка (Сенет) ≥ 50
  • Стресс ≥ 60%

Выбор пункта ведёт на экран уговоров остаться (stateStoryEscapeAttempt) с двумя вариантами: принять успокоительное (стресс мгновенно падает до 0, без последующего часового восстановления — в отличие от антидепрессанта) или закончить игру досрочно (ведёт на финальный экран и обратно в меню режимов; сохранение сторимода при этом не удаляется).

3.12. Сброс прогресса

Клавиша [N] в лагере (доступна и на этапе знакомства) запрашивает подтверждение (y/n), и по «да» удаляет story_save.json и ведёт на экран выбора сложности заново — тот же паттерн N + подтверждение, что уже был в Трупных батончиках.