# Архитектура проекта Коллекция терминальных игр на 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. Начисление ресурса — суточный лимит Победа сама по себе не гарантирует ресурс: `AwardResource` (в `story_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. Показатели выживания Три процентных показателя (0–100), видны в лагере только после завершения знакомства: - **Стресс** (`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` + подтверждение, что уже был в Трупных батончиках.