diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1908cbd --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,342 @@ +# Архитектура проекта + +Коллекция терминальных игр на 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` + подтверждение, +что уже был в Трупных батончиках. diff --git a/blackjack_game.go b/blackjack_game.go index a1d8ba2..ff03815 100644 --- a/blackjack_game.go +++ b/blackjack_game.go @@ -73,6 +73,11 @@ type BlackjackGameState struct { StartingCapital int Result *BlackjackResult + // RoundsPlayed — сколько раундов уже сыграно, включая текущий. + // Нужно только для правила сторимода "минимум 3 раунда, чтобы + // засчиталась победа" — на обычную игру никак не влияет. + RoundsPlayed int + // RoundBetReduced[i] == true, если у игрока i не хватило баланса // на полную ставку Bet в этом раунде — тогда он честно ставит // всё, что у него осталось (в том числе 0, если денег нет вовсе), @@ -157,6 +162,7 @@ func NewBlackjackRound(names []string, humanIdx int, startingCapital, bet int, b Deck: deck, Bet: bet, StartingCapital: startingCapital, + RoundsPlayed: 1, } g.preRoundBalance = make([]int, len(players)) @@ -351,5 +357,7 @@ func (g *BlackjackGameState) NextRound() *BlackjackGameState { humanIdx = i } } - return NewBlackjackRound(names, humanIdx, g.StartingCapital, g.Bet, balances) + next := NewBlackjackRound(names, humanIdx, g.StartingCapital, g.Bet, balances) + next.RoundsPlayed = g.RoundsPlayed + 1 + return next } diff --git a/corpsestarch_game.go b/corpsestarch_game.go index 0cd73a7..eba4ac4 100644 --- a/corpsestarch_game.go +++ b/corpsestarch_game.go @@ -544,4 +544,4 @@ func (g *CorpseStarchGameState) ServitorUnlocked(idx int) bool { // MissionUnlocked проверяет, видна ли конкретная миссия idx. func (g *CorpseStarchGameState) MissionUnlocked(idx int) bool { return g.TotalEarned >= corpseStarchMissionDefs[idx].UnlockAt -} \ No newline at end of file +} diff --git a/corpsestarch_tui.go b/corpsestarch_tui.go index 7922021..6ad6922 100644 --- a/corpsestarch_tui.go +++ b/corpsestarch_tui.go @@ -418,4 +418,4 @@ func (m CorpseStarchModel) View() string { fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.hint"))) return b.String() -} \ No newline at end of file +} diff --git a/corpsestarch_tui_test.go b/corpsestarch_tui_test.go index ac4c0fd..41ddf8c 100644 --- a/corpsestarch_tui_test.go +++ b/corpsestarch_tui_test.go @@ -510,4 +510,4 @@ func TestCorpseStarchImperialModeStillUsesOriginalWording(t *testing.T) { if !strings.Contains(view, "трупный крахмал") && !strings.Contains(view, "Трупный крахмал") { t.Errorf("в имперском режиме заголовок должен был по-прежнему упоминать трупный крахмал, получено:\n%s", view) } -} \ No newline at end of file +} diff --git a/durak_tui.go b/durak_tui.go index 8d62c26..e70f3f4 100644 --- a/durak_tui.go +++ b/durak_tui.go @@ -23,6 +23,11 @@ type DurakModel struct { quitting bool backToMenu bool + + // storyNonFoolStreak — сколько подряд партий в этой сессии + // человек НЕ оказывался дураком (сбрасывается при первом же + // поражении). Нужно только сторимоду; на обычную игру не влияет. + storyNonFoolStreak int } // NewDurakModel создаёт новую партию Дурака на numPlayers игроков @@ -154,7 +159,13 @@ func (m DurakModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch key { case "n": numPlayers := len(m.game.Players) + wasFool := m.game.Result != nil && !m.game.Result.NoLoser && !m.game.Result.Stalemate && m.game.Result.Loser == m.humanIdx + streak := m.storyNonFoolStreak + 1 + if wasFool { + streak = 0 + } m = NewDurakModel(numPlayers) + m.storyNonFoolStreak = streak return m, m.maybeScheduleBot() case "q": m.backToMenu = true diff --git a/game.go b/game.go index eb84292..90b63ec 100644 --- a/game.go +++ b/game.go @@ -99,6 +99,11 @@ type GameState struct { // уровень, функцию checkBankruptcies в tui.go). StartingCapital int + // RoundsPlayed — сколько раундов уже сыграно, включая текущий. + // Нужно только для правила сторимода "минимум 3 раунда" — на + // обычную игру никак не влияет. + RoundsPlayed int + // preRoundBalance — капитал каждого игрока непосредственно // перед списанием анте в начале раунда. Используется только // для вычисления BalanceDeltas в RoundResult (чтобы показать @@ -159,7 +164,9 @@ func (g *GameState) NextRound() *GameState { isHuman[i] = p.IsHuman balances[i] = p.Balance } - return newRound(names, isHuman, balances, g.TonkThreshold, g.StartingCapital, g.AnteAmount, g.TonkMultiplier) + next := newRound(names, isHuman, balances, g.TonkThreshold, g.StartingCapital, g.AnteAmount, g.TonkMultiplier) + next.RoundsPlayed = g.RoundsPlayed + 1 + return next } // newRound — общая внутренняя логика раздачи новой партии/раунда: @@ -191,6 +198,7 @@ func newRound(names []string, isHuman []bool, balances []int, tonkThreshold, sta AnteAmount: ante, TonkMultiplier: tonkMultiplier, StartingCapital: startingCapital, + RoundsPlayed: 1, } g.collectAnte() return g diff --git a/ggc b/ggc index a45c699..fead10e 100755 --- a/ggc +++ b/ggc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f96bd1922b45b6b68caf900d8d79d3815118b306be9bafa1746c148b18a8ab36 -size 5042338 +oid sha256:814093914a7b7c80c42934319693b2037ee54acae969273174c9db019c837035 +size 5140642 diff --git a/ggc.exe b/ggc.exe index 61c8d6a..238f4a6 100755 --- a/ggc.exe +++ b/ggc.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b141ba6db6a1ce0b3ccfd8f27d1d946ab50c74f7a77c526230ae8177972c7c1d -size 5329408 +oid sha256:0ddba822d485c8ddbcf6c62d0177d7bcd80df00e356e480df342ad8bece04011 +size 5432832 diff --git a/menu.go b/menu.go index 527199b..9edb9bc 100644 --- a/menu.go +++ b/menu.go @@ -225,6 +225,17 @@ type appState int const ( stateLanguage appState = iota + stateModeSelect + stateStoryDifficulty + stateStoryWelcome + stateStoryMorning + stateStoryNeurotoxinAlert + stateStoryDoorKnockAlert + stateStoryHub + stateStoryConditions + stateStoryUseItem + stateStoryEscapeAttempt + stateStoryEscapeEnding stateMenu stateRulesMenu stateRules @@ -743,30 +754,40 @@ func (opt menuOption) desc() string { // AppModel — корневая модель приложения: меню, экран правил или // запущенная игра. type AppModel struct { - state appState - langCursor int - cursor int - menuScroll int // индекс первого видимого пункта главного меню - rulesCursor int - rulesMenuScroll int // индекс первого видимого пункта подменю правил - rulesScroll int // сколько строк текста правил прокручено вниз - termWidth int - termHeight int - options []menuOption - rulesText string - settings stakeSettings - playerCount playerCountSettings - blackjack blackjackSettings - checkers checkersSettings - ticTacToe ticTacToeSettings - nardy nardySettings - ur urSettings - senet senetSettings - chess chessSettings - goSetup goSettings - minesweeper minesweeperSettings - activeGame tea.Model - quitting bool + state appState + langCursor int + modeCursor int + + story *StoryModeState + storyDifficultyCursor int + storyWelcomeWordsShown int + storyGreeting storyGreetingKind + storyPostGameFlavor string + storyConfirmingReset bool + storyHubCursor int + storyActiveResource *StoryResourceID + cursor int + menuScroll int // индекс первого видимого пункта главного меню + rulesCursor int + rulesMenuScroll int // индекс первого видимого пункта подменю правил + rulesScroll int // сколько строк текста правил прокручено вниз + termWidth int + termHeight int + options []menuOption + rulesText string + settings stakeSettings + playerCount playerCountSettings + blackjack blackjackSettings + checkers checkersSettings + ticTacToe ticTacToeSettings + nardy nardySettings + ur urSettings + senet senetSettings + chess chessSettings + goSetup goSettings + minesweeper minesweeperSettings + activeGame tea.Model + quitting bool } func NewAppModel() AppModel { @@ -792,6 +813,13 @@ func (m AppModel) Init() tea.Cmd { } func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "Q" { + // заглавная Q — мгновенный выход из программы целиком, с + // любого экрана и из любой глубины вложенности (в отличие от + // строчной q, которая почти везде означает "на уровень назад") + m.quitting = true + return m, tea.Quit + } if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { m.termWidth = sizeMsg.Width m.termHeight = sizeMsg.Height @@ -816,6 +844,28 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updatePlaying(msg) case stateLanguage: return m.updateLanguage(msg) + case stateModeSelect: + return m.updateModeSelect(msg) + case stateStoryDifficulty: + return m.updateStoryDifficulty(msg) + case stateStoryWelcome: + return m.updateStoryWelcome(msg) + case stateStoryMorning: + return m.updateStoryMorning(msg) + case stateStoryNeurotoxinAlert: + return m.updateStoryNeurotoxinAlert(msg) + case stateStoryDoorKnockAlert: + return m.updateStoryDoorKnockAlert(msg) + case stateStoryHub: + return m.updateStoryHub(msg) + case stateStoryConditions: + return m.updateStoryConditions(msg) + case stateStoryUseItem: + return m.updateStoryUseItem(msg) + case stateStoryEscapeAttempt: + return m.updateStoryEscapeAttempt(msg) + case stateStoryEscapeEnding: + return m.updateStoryEscapeEnding(msg) case stateRulesMenu: return m.updateRulesMenu(msg) case stateRules: @@ -866,7 +916,42 @@ func (m AppModel) updateLanguage(msg tea.Msg) (tea.Model, tea.Cmd) { } case "enter": SetLanguage(Lang(m.langCursor)) - m.state = stateMenu + m.state = stateModeSelect + } + return m, nil +} + +func (m AppModel) updateModeSelect(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateLanguage + return m, nil + case "up", "k": + if m.modeCursor > 0 { + m.modeCursor-- + } + case "down", "j": + if m.modeCursor < 1 { + m.modeCursor++ + } + case "enter": + if m.modeCursor == 0 { + m.state = stateMenu + } else { + if saved, err := LoadStoryModeState(); err == nil { + m.story = saved + m = m.enterStoryHubFromModeSelect() + } else { + m.state = stateStoryDifficulty + } + } } return m, nil } @@ -1524,6 +1609,39 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { backToMenu = tm.backToMenu } if backToMenu { + if m.storyActiveResource != nil { + id := *m.storyActiveResource + if def, ok := storyGameDefs[id]; ok { + if finished, won := def.Outcome(m.activeGame); finished && won { + if id == StoryResKlondike { + // у Косынки особое правило — 3 победы за + // один день, а не начисление на каждую; + // суточный лимит начислений (AwardResource) + // проверяется уже поверх этого правила + m.story.KlondikeWinsToday++ + if m.story.KlondikeWinsToday == 3 { + m.story.AwardResource(id) + } + } else { + m.story.AwardResource(id) + } + } + } + m.storyActiveResource = nil + m.activeGame = nil + if m.story.AdvanceOnboarding() { + // именно этим исходом партии знакомство завершилось — + // показываем приветствие один-единственный раз, и уже + // после него откроется обычный лагерь целиком + _ = SaveStoryModeState(m.story) + m.state = stateStoryWelcome + m.storyWelcomeWordsShown = 0 + return m, storyWelcomeTick() + } + m.storyPostGameFlavor = randomStoryPostGameFlavor() + m = m.enterStoryHub() + return m, nil + } m.state = stateMenu m.activeGame = nil return m, nil @@ -1540,6 +1658,28 @@ func (m AppModel) View() string { return m.activeGame.View() case stateLanguage: return m.viewLanguage() + case stateModeSelect: + return m.viewModeSelect() + case stateStoryDifficulty: + return m.viewStoryDifficulty() + case stateStoryWelcome: + return m.viewStoryWelcome() + case stateStoryMorning: + return m.viewStoryMorning() + case stateStoryNeurotoxinAlert: + return m.viewStoryNeurotoxinAlert() + case stateStoryDoorKnockAlert: + return m.viewStoryDoorKnockAlert() + case stateStoryHub: + return m.viewStoryHub() + case stateStoryConditions: + return m.viewStoryConditions() + case stateStoryUseItem: + return m.viewStoryUseItem() + case stateStoryEscapeAttempt: + return m.viewStoryEscapeAttempt() + case stateStoryEscapeEnding: + return m.viewStoryEscapeEnding() case stateRulesMenu: return m.viewRulesMenu() case stateRules: @@ -1591,6 +1731,26 @@ func (m AppModel) viewLanguage() string { return b.String() } +func (m AppModel) viewModeSelect() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("modeselect.title"))) + fmt.Fprintln(&b) + + options := []string{T("modeselect.games"), T("modeselect.story")} + for i, opt := range options { + cursor := " " + label := menuItemStyle.Render(opt) + if i == m.modeCursor { + cursor = menuCursorStyle.Render("> ") + label = menuCursorStyle.Render(opt) + } + fmt.Fprintf(&b, "%s%s\n", cursor, label) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint"))) + return b.String() +} + func (m AppModel) viewMenu() string { var b strings.Builder if m.menuUsesCompactBanner() { @@ -1993,4 +2153,4 @@ func (m AppModel) viewMinesweeperSettings() string { fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("mssettings.hint"))) return b.String() -} +} \ No newline at end of file diff --git a/menu_test.go b/menu_test.go index eb1de48..c9722cd 100644 --- a/menu_test.go +++ b/menu_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -59,7 +60,9 @@ func TestQReturnsToMenuForAllGames(t *testing.T) { func newTestAppModel() AppModel { m := NewAppModel() - next, _ := m.Update(key("enter")) + next, _ := m.Update(key("enter")) // подтверждаем язык -> экран выбора режима + m = next.(AppModel) + next, _ = m.Update(key("enter")) // подтверждаем "Просто игры" (курсор по умолчанию на нём) -> меню игр return next.(AppModel) } @@ -86,8 +89,8 @@ func TestLanguageScreenSelectEnglish(t *testing.T) { next, _ = m.Update(key("enter")) m = next.(AppModel) - if m.state != stateMenu { - t.Fatalf("ожидался переход в stateMenu после выбора языка") + if m.state != stateModeSelect { + t.Fatalf("ожидался переход в stateModeSelect после выбора языка") } if CurrentLang() != LangEN { t.Fatalf("ожидался установленный язык LangEN") @@ -102,14 +105,84 @@ func TestLanguageScreenDefaultsToRussian(t *testing.T) { m := NewAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) - if m.state != stateMenu { - t.Fatalf("ожидался переход в stateMenu") + if m.state != stateModeSelect { + t.Fatalf("ожидался переход в stateModeSelect") } if CurrentLang() != LangRU { t.Fatalf("ожидался язык по умолчанию LangRU") } } +func TestModeSelectGamesLeadsToMenu(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + if m.state != stateModeSelect { + t.Fatalf("ожидался stateModeSelect, получено %v", m.state) + } + if m.modeCursor != 0 { + t.Fatalf("ожидался курсор по умолчанию на 'Просто игры' (0), получено %d", m.modeCursor) + } + + next, _ = m.Update(key("enter")) // подтверждаем "Просто игры" + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("выбор 'Просто игры' должен был вести в stateMenu, получено %v", m.state) + } +} + +func TestModeSelectStoryLeadsToDifficultyWhenNoSave(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + + next, _ = m.Update(key("down")) // переключаемся на "Режим истории" + m = next.(AppModel) + if m.modeCursor != 1 { + t.Fatalf("ожидался курсор на 'Режим истории' (1), получено %d", m.modeCursor) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateStoryDifficulty { + t.Fatalf("выбор 'Режим истории' без сохранения должен был вести в stateStoryDifficulty, получено %v", m.state) + } +} + +func TestStoryDifficultyEscReturnsToModeSelect(t *testing.T) { + m := NewAppModel() + m.state = stateStoryDifficulty + + next, _ := m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateModeSelect { + t.Errorf("esc из выбора сложности сторимода должен был вернуть на выбор режима, получено %v", m.state) + } +} + +func TestModeSelectEscReturnsToLanguage(t *testing.T) { + m := NewAppModel() + m.state = stateModeSelect + + next, _ := m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateLanguage { + t.Errorf("esc из выбора режима должен был вернуть на выбор языка, получено %v", m.state) + } +} + +func TestModeSelectQuitViaCtrlC(t *testing.T) { + m := NewAppModel() + m.state = stateModeSelect + + next, _ := m.Update(key("ctrl+c")) + m = next.(AppModel) + if !m.quitting { + t.Errorf("ctrl+c на экране выбора режима должен был завершить приложение") + } +} + func TestMenuHasLanguageOption(t *testing.T) { m := newTestAppModel() found := false @@ -1588,3 +1661,57 @@ func TestGameQKeyReturnsToMenuNotQuitsApp(t *testing.T) { func TestAppModelImplementsTeaModel(t *testing.T) { var _ tea.Model = AppModel{} } + +func TestCapitalQQuitsFromLanguageScreen(t *testing.T) { + m := NewAppModel() + next, _ := m.Update(key("Q")) + m = next.(AppModel) + if !m.quitting { + t.Errorf("заглавная Q должна была завершить приложение с экрана выбора языка") + } +} + +func TestCapitalQQuitsFromModeSelect(t *testing.T) { + m := AppModel{state: stateModeSelect} + next, _ := m.Update(key("Q")) + m = next.(AppModel) + if !m.quitting { + t.Errorf("заглавная Q должна была завершить приложение с экрана выбора режима") + } +} + +func TestCapitalQQuitsFromDeepInsideStoryMode(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + m.state = stateStoryUseItem // произвольный глубоко вложенный экран сторимода + + next, _ := m.Update(key("Q")) + m = next.(AppModel) + if !m.quitting { + t.Errorf("заглавная Q должна была завершить приложение даже из глубоко вложенного экрана сторимода") + } +} + +func TestCapitalQQuitsWhileGameIsActive(t *testing.T) { + m := AppModel{state: statePlaying, activeGame: NewTicTacToeModel(TicTacToeDifficultyEasy)} + + next, _ := m.Update(key("Q")) + m = next.(AppModel) + if !m.quitting { + t.Errorf("заглавная Q должна была завершить приложение даже во время активной партии") + } +} + +func TestLowercaseQDoesNotQuitAppFromModeSelect(t *testing.T) { + m := AppModel{state: stateModeSelect} + next, _ := m.Update(key("q")) + m = next.(AppModel) + if m.quitting { + t.Errorf("строчная q не должна завершать приложение целиком — только возвращать на уровень назад") + } + if m.state != stateLanguage { + t.Errorf("строчная q должна была вернуть на экран выбора языка, получено %v", m.state) + } +} \ No newline at end of file diff --git a/minesweeper_tui.go b/minesweeper_tui.go index 8a81035..77d9105 100644 --- a/minesweeper_tui.go +++ b/minesweeper_tui.go @@ -29,6 +29,11 @@ type MinesweeperModel struct { quitting bool backToMenu bool + + // storyWinStreak — сколько подряд партий в этой сессии выиграно + // без взрыва (сбрасывается при первом же поражении). Нужно + // только сторимоду; на обычную игру не влияет. + storyWinStreak int } // NewMinesweeperModel создаёт новую партию width x height с mines @@ -77,6 +82,11 @@ func (m MinesweeperModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.game.Result != nil { if key == "n" { + if m.game.Result.Won { + m.storyWinStreak++ + } else { + m.storyWinStreak = 0 + } m.game = NewMinesweeperGame(m.width, m.height, m.mines) m.cursor = MinesweeperPos{Row: m.height / 2, Col: m.width / 2} m.message = "" diff --git a/story_flow_test.go b/story_flow_test.go new file mode 100644 index 0000000..8e3f6b7 --- /dev/null +++ b/story_flow_test.go @@ -0,0 +1,1225 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestStoryDifficultySelectionGoesStraightToOnboardingHub(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.state = stateStoryDifficulty + m.storyDifficultyCursor = 2 // "Сложная" + + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Fatalf("после выбора сложности ожидался переход сразу в stateStoryHub (этап знакомства), получено %v", m.state) + } + if m.story == nil { + t.Fatalf("ожидалось созданное состояние сторимода") + } + if m.story.Difficulty != StoryDifficultyHard { + t.Errorf("ожидалась сложность StoryDifficultyHard, получено %v", m.story.Difficulty) + } + if m.story.Onboarding != StoryOnboardingTicTacToeOnly { + t.Errorf("новая партия должна была начаться с этапа знакомства (только крестики-нолики), получено %v", m.story.Onboarding) + } +} + +func TestStoryDifficultySelectionPersistsToSave(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.state = stateStoryDifficulty + m.storyDifficultyCursor = 1 + + next, _ := m.Update(key("enter")) + _ = next.(AppModel) + + loaded, err := LoadStoryModeState() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + if loaded.Difficulty != StoryDifficultyMedium { + t.Errorf("сохранённая сложность должна была быть Medium, получено %v", loaded.Difficulty) + } +} + +func TestModeSelectStoryWithExistingSaveShowsGreetingThenHub(t *testing.T) { + withTempConfigDir(t) + pre := NewStoryModeState(StoryDifficultyEasy, time.Now()) + pre.Onboarding = StoryOnboardingDone // приветствие дня показывается только после знакомства + if err := SaveStoryModeState(pre); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + next, _ = m.Update(key("down")) // курсор на "Режим истории" + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + + if m.state != stateStoryMorning { + t.Fatalf("при входе через меню режимов ожидалось приветствие дня, получено %v", m.state) + } + if m.storyGreeting != storyGreetingPlayingAgain { + t.Errorf("при входе в тот же день ожидалось приветствие storyGreetingPlayingAgain, получено %v", m.storyGreeting) + } + + next, _ = m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Fatalf("после приветствия ожидался переход в stateStoryHub, получено %v", m.state) + } + if m.story == nil || m.story.Difficulty != StoryDifficultyEasy { + t.Fatalf("ожидалось загруженное состояние с сохранённой сложностью, получено %+v", m.story) + } +} + +func TestStoryHubLaunchesPlayableGame(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + m.storyHubCursor = 0 // Крестики-нолики — единственная видимая игра на этапе знакомства + + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидался переход в statePlaying после запуска игры, получено %v", m.state) + } + if m.storyActiveResource == nil || *m.storyActiveResource != StoryResTicTacToe { + t.Fatalf("ожидался отмеченный активный ресурс StoryResTicTacToe") + } + if _, ok := m.activeGame.(TicTacToeModel); !ok { + t.Errorf("ожидалась запущенная модель TicTacToeModel, получено %T", m.activeGame) + } +} + +func TestAllStoryHubGamesAreWiredInStoryGameDefs(t *testing.T) { + for _, id := range storyHubOrder { + if _, ok := storyGameDefs[id]; !ok { + t.Errorf("ресурс %v есть в storyHubOrder, но не подключён в storyGameDefs", id) + } + } +} + +func TestStoryHubStillLaunchesLockedGame(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone // тест про полный хаб, не про этап знакомства + m.story.Locked[StoryResTicTacToe] = true + m.state = stateStoryHub + m.storyHubCursor = indexOfStoryResource(t, StoryResTicTacToe) + + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.activeGame == nil { + t.Errorf("уже зафиксированная (запертая) игра должна была оставаться играбельной") + } +} + +func TestWinningStoryGameAwardsResourceAndReturnsToHub(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + before := m.story.Resources[StoryResTicTacToe] + + tm := NewTicTacToeModel(TicTacToeDifficultyEasy) + tm.game.Phase = TicTacToePhaseOver + tm.game.Result = &TicTacToeResult{Winner: tm.game.HumanMark} + tm.backToMenu = true + + resource := StoryResTicTacToe + m.storyActiveResource = &resource + m.activeGame = tm + m.state = statePlaying + + next, _ := m.Update(key("q")) // ключ не важен — уже готовый backToMenu=true проверяется в Update верхнего уровня + m2, ok := next.(AppModel) + if !ok { + t.Fatalf("ожидался AppModel в ответе") + } + + if m2.state != stateStoryHub { + t.Fatalf("после победы в игре сторимода ожидался возврат в stateStoryHub, получено %v", m2.state) + } + if m2.story.Resources[StoryResTicTacToe] != before+1 { + t.Errorf("ожидалось начисление +1 ресурса за победу, получено %d (было %d)", m2.story.Resources[StoryResTicTacToe], before) + } + if m2.storyActiveResource != nil { + t.Errorf("отметка активного ресурса должна была сброситься после возврата") + } +} + +func TestLosingStoryGameDoesNotAwardResource(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + before := m.story.Resources[StoryResTicTacToe] + + tm := NewTicTacToeModel(TicTacToeDifficultyEasy) + tm.game.Phase = TicTacToePhaseOver + loser := TicTacToeX + if tm.game.HumanMark == TicTacToeX { + loser = TicTacToeO + } + tm.game.Result = &TicTacToeResult{Winner: loser} + tm.backToMenu = true + + resource := StoryResTicTacToe + m.storyActiveResource = &resource + m.activeGame = tm + m.state = statePlaying + + next, _ := m.Update(key("q")) + m2 := next.(AppModel) + + if m2.story.Resources[StoryResTicTacToe] != before { + t.Errorf("поражение не должно было начислять ресурс, получено %d (было %d)", m2.story.Resources[StoryResTicTacToe], before) + } + if m2.state != stateStoryHub { + t.Errorf("после поражения тоже ожидался возврат в хаб, получено %v", m2.state) + } +} + +func TestStoryResourceLocksExactlyAtTarget(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResChess] = StoryTargetChess - 1 + s.RecordWin(StoryResChess) + if !s.Locked[StoryResChess] { + t.Fatalf("ресурс должен был зафиксироваться по достижении цели") + } +} + +// indexOfStoryResource находит позицию ресурса в storyHubOrder — не +// хардкодим индекс напрямую, чтобы тест не сломался при изменении +// порядка показа в хабе. +func indexOfStoryResource(t *testing.T, id StoryResourceID) int { + t.Helper() + for i, r := range storyHubOrder { + if r == id { + return i + } + } + t.Fatalf("ресурс %v не найден в storyHubOrder", id) + return -1 +} + +func TestStoryOutcomeBlackjackRequiresThreeRoundsMinimum(t *testing.T) { + bm := NewBlackjackModel(1, 500, 25) + for bm.game.Phase == BJPhasePlayerTurn { + bm.game.Stand() + } + bm.game.RoundsPlayed = 2 + bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital + 1000 // в большом плюсе, но раундов мало + + finished, won := storyOutcomeBlackjack(bm) + if !finished { + t.Fatalf("после завершения раунда партия должна была считаться finished") + } + if won { + t.Errorf("меньше трёх раундов не должно засчитываться победой, даже при явном плюсе") + } +} + +func TestStoryOutcomeBlackjackWinsWithThreeRoundsAndProfit(t *testing.T) { + bm := NewBlackjackModel(1, 500, 25) + for bm.game.Phase == BJPhasePlayerTurn { + bm.game.Stand() + } + bm.game.RoundsPlayed = 3 + bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital + 1 + + _, won := storyOutcomeBlackjack(bm) + if !won { + t.Errorf("три раунда и баланс выше стартового должны были засчитаться победой") + } +} + +func TestStoryOutcomeBlackjackNoWinWithoutProfit(t *testing.T) { + bm := NewBlackjackModel(1, 500, 25) + for bm.game.Phase == BJPhasePlayerTurn { + bm.game.Stand() + } + bm.game.RoundsPlayed = 5 + bm.game.Players[bm.humanIdx].Balance = bm.game.StartingCapital // ровно столько же, не "выше" + + _, won := storyOutcomeBlackjack(bm) + if won { + t.Errorf("баланс не выше стартового (даже после многих раундов) не должен засчитываться победой") + } +} + +func TestStoryOutcomeBlackjackNotFinishedMidRound(t *testing.T) { + bm := NewBlackjackModel(1, 500, 25) + bm.game.Phase = BJPhasePlayerTurn // принудительно, чтобы не зависеть от случайного натурального блэкджека с раздачи + finished, _ := storyOutcomeBlackjack(bm) + if finished { + t.Errorf("партия в процессе раунда не должна была считаться завершённой") + } +} + +func TestTicTacToeDrawCountsAsStoryWin(t *testing.T) { + tm := NewTicTacToeModel(TicTacToeDifficultyEasy) + tm.game.Phase = TicTacToePhaseOver + tm.game.Result = &TicTacToeResult{Winner: TicTacToeEmpty} // ничья + finished, won := storyOutcomeTicTacToe(tm) + if !finished || !won { + t.Errorf("ничья должна была засчитаться как победа в сторимоде, получено finished=%v won=%v", finished, won) + } +} + +func TestTicTacToeStoryTargetIsTen(t *testing.T) { + if StoryTargetTicTacToe != 10 { + t.Errorf("ожидалась цель 10 для крестиков-ноликов, получено %d", StoryTargetTicTacToe) + } +} + +func TestEnterStoryHubFromModeSelectShowsMorningScreenAfterADay(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + past := time.Now().Add(-25 * time.Hour) + m.story = NewStoryModeState(StoryDifficultyEasy, past) + m.story.Onboarding = StoryOnboardingDone + + m = m.enterStoryHubFromModeSelect() + if m.state != stateStoryMorning { + t.Fatalf("после прошедших суток ожидался утренний экран, получено %v", m.state) + } + if m.storyGreeting != storyGreetingWokeUp { + t.Errorf("после ровно одних прошедших суток ожидалось приветствие storyGreetingWokeUp, получено %v", m.storyGreeting) + } + // возможные случайно сработавшие события (выброс/стук) не относятся + // к тому, что здесь проверяется — своя логика очереди уже + // покрыта отдельными тестами + m.story.PendingNeurotoxinAlert = false + m.story.PendingDoorKnockAlert = false + + next, _ := m.Update(key(" ")) + m2 := next.(AppModel) + if m2.state != stateStoryHub { + t.Errorf("после утреннего экрана ожидался переход в хаб, получено %v", m2.state) + } +} + +func TestEnterStoryHubFromModeSelectSkipsGreetingDuringOnboarding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) // по умолчанию ещё на этапе знакомства + + m = m.enterStoryHubFromModeSelect() + if m.state != stateStoryHub { + t.Errorf("во время знакомства (ИИ ещё не представилась) приветствие дня показываться не должно, получено %v", m.state) + } +} + +func TestEnterStoryHubFromModeSelectShowsGreetingAfterOnboarding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + + m = m.enterStoryHubFromModeSelect() + if m.state != stateStoryMorning { + t.Errorf("после завершения знакомства вход через меню режимов должен был показывать одно из приветствий, получено %v", m.state) + } + if m.storyGreeting != storyGreetingPlayingAgain { + t.Errorf("в тот же день ожидалось приветствие storyGreetingPlayingAgain, получено %v", m.storyGreeting) + } +} + +func TestEnterStoryHubAfterGameNeverShowsGreeting(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + past := time.Now().Add(-50 * time.Hour) // прошло несколько суток + m.story = NewStoryModeState(StoryDifficultyEasy, past) + + m = m.enterStoryHub() + if m.state != stateStoryHub { + t.Errorf("возврат из партии не должен показывать приветствие дня даже после нескольких прошедших суток, получено %v", m.state) + } +} + +func TestStoryOutcomeTonkRequiresThreeRoundsAndMaxBalance(t *testing.T) { + tm := NewModel() + tm.game.Phase = PhaseGameOver + tm.game.RoundsPlayed = 2 + tm.game.Players[tm.humanIdx].Balance = 999999 // в большом плюсе, но раундов мало + if _, won := storyOutcomeTonk(tm); won { + t.Errorf("меньше трёх раундов не должно засчитываться победой") + } + + tm.game.RoundsPlayed = 3 + for _, p := range tm.game.Players { + p.Balance = 0 + } + tm.game.Players[tm.humanIdx].Balance = 100 + if _, won := storyOutcomeTonk(tm); !won { + t.Errorf("три раунда и максимальный баланс должны были засчитаться победой") + } +} + +func TestStoryOutcomeDurakStreakResetsOnLoss(t *testing.T) { + dm := NewDurakModel(4) + dm.storyNonFoolStreak = 2 + dm.game.Result = &DurakResult{Loser: dm.humanIdx} + if _, won := storyOutcomeDurak(dm); won { + t.Errorf("поражение в решающей партии должно было сбросить серию, а не засчитать победу") + } +} + +func TestStoryOutcomeDurakStreakCountsCurrentGame(t *testing.T) { + dm := NewDurakModel(4) + dm.storyNonFoolStreak = 2 // уже 2 подряд без дурака + other := (dm.humanIdx + 1) % len(dm.game.Players) + dm.game.Result = &DurakResult{Loser: other} // в этой партии дураком стал не человек + if _, won := storyOutcomeDurak(dm); !won { + t.Errorf("третья подряд партия без дурака должна была засчитаться победой") + } +} + +func TestStoryOutcomeMinesweeperStreakResetsOnLoss(t *testing.T) { + mm := NewMinesweeperModel(9, 9, 10) + mm.storyWinStreak = 2 + mm.game.Result = &MinesweeperResult{Won: false} + if _, won := storyOutcomeMinesweeper(mm); won { + t.Errorf("взрыв в решающей партии должен был сбросить серию, а не засчитать победу") + } +} + +func TestStoryOutcomeMinesweeperStreakCountsCurrentGame(t *testing.T) { + mm := NewMinesweeperModel(9, 9, 10) + mm.storyWinStreak = 2 + mm.game.Result = &MinesweeperResult{Won: true} + if _, won := storyOutcomeMinesweeper(mm); !won { + t.Errorf("третья подряд победа без взрыва должна была засчитаться") + } +} + +func TestStoryOutcomeOneOhOneRequiresMatchOverNotJustRound(t *testing.T) { + om := NewOneOhOneModel(3) + om.game.Phase = OneOhOnePhaseRoundOver + om.game.Result = &OneOhOneRoundResult{Winner: om.humanIdx, MatchOver: false} + if finished, _ := storyOutcomeOneOhOne(om); finished { + t.Errorf("отдельный раунд не должен засчитываться завершением партии — нужен весь матч") + } + + om.game.Result.MatchOver = true + om.game.Result.MatchWinner = om.humanIdx + if finished, won := storyOutcomeOneOhOne(om); !finished || !won { + t.Errorf("выигранный матч должен был засчитаться победой, получено finished=%v won=%v", finished, won) + } +} + +func TestStoryOutcomeThousandRequiresThreeHandsAndTopScore(t *testing.T) { + tm := NewThousandModel() + tm.game.Phase = ThousandPhaseHandOver + tm.game.HandNumber = 2 + tm.game.Players[tm.humanIdx].Score = 999999 + if _, won := storyOutcomeThousand(tm); won { + t.Errorf("меньше трёх раздач не должно засчитываться победой") + } + + tm.game.HandNumber = 3 + for _, p := range tm.game.Players { + p.Score = 0 + } + tm.game.Players[tm.humanIdx].Score = 50 + if _, won := storyOutcomeThousand(tm); !won { + t.Errorf("три раздачи и первое место по очкам должны были засчитаться победой") + } +} + +func TestStoryOutcomeKlondikeJustReportsWinFlag(t *testing.T) { + km := NewKlondikeModel() + km.game.Won = false + if finished, _ := storyOutcomeKlondike(km); finished { + t.Errorf("незавершённая партия не должна была считаться выигранной") + } + km.game.Won = true + if finished, won := storyOutcomeKlondike(km); !finished || !won { + t.Errorf("выигранная партия должна была вернуть finished=true won=true") + } +} + +func TestKlondikeDailyCounterAwardsResourceOnThirdWinOfDay(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + + km := NewKlondikeModel() + km.game.Won = true + km.backToMenu = true + + for i := 0; i < 3; i++ { + resource := StoryResKlondike + m.storyActiveResource = &resource + m.activeGame = km + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + } + + if m.story.Resources[StoryResKlondike] != storyStartingAmount+1 { + t.Errorf("после третьей победы за день ожидалось начисление ровно +1 ресурса, получено %d (было %d)", + m.story.Resources[StoryResKlondike], storyStartingAmount) + } + if m.story.KlondikeWinsToday != 3 { + t.Errorf("ожидался счётчик побед за день = 3, получено %d", m.story.KlondikeWinsToday) + } +} + +func TestStoryOutcomePokerRequiresTournamentOverAndNotEliminated(t *testing.T) { + pm := NewPokerModel(4) + if finished, _ := storyOutcomePoker(pm); finished { + t.Errorf("турнир в процессе не должен считаться завершённым") + } + + pm.game.Phase = PokerPhaseTournamentOver + pm.game.Players[pm.humanSeat].Eliminated = false + if finished, won := storyOutcomePoker(pm); !finished || !won { + t.Errorf("невыбывший человек при завершении турнира должен был засчитаться победителем") + } + + pm.game.Players[pm.humanSeat].Eliminated = true + if _, won := storyOutcomePoker(pm); won { + t.Errorf("выбывший человек не должен был засчитаться победителем турнира") + } +} + +func TestExistingSaveSkipsWelcomeScreen(t *testing.T) { + withTempConfigDir(t) + pre := NewStoryModeState(StoryDifficultyEasy, time.Now()) + pre.Onboarding = StoryOnboardingDone + if err := SaveStoryModeState(pre); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + next, _ = m.Update(key("down")) // курсор на "Режим истории" + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + + if m.state != stateStoryMorning { + t.Fatalf("ожидалось ежедневное приветствие (не одноразовое приветствие ИИ), получено %v", m.state) + } + if m.state == stateStoryWelcome { + t.Fatalf("одноразовое приветствие ИИ не должно было показаться повторно") + } +} + +func TestStoryConditionsScreenListsAllGames(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone // клавиша c недоступна во время знакомства + m.state = stateStoryHub + + next, _ := m.Update(key("c")) + m = next.(AppModel) + if m.state != stateStoryConditions { + t.Fatalf("клавиша c должна была открыть экран условий, получено %v", m.state) + } + view := m.View() + for _, id := range storyHubOrder { + resDef := storyResourceDefs[id] + if !strings.Contains(view, T(resDef.NameKey)) { + t.Errorf("экран условий должен был упоминать %q", T(resDef.NameKey)) + } + } + + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("любая клавиша на экране условий должна была вернуть в хаб, получено %v", m.state) + } +} + +func TestStoryEscapeOptionHiddenUntilConditionMet(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + if m.storyEscapeUnlocked() { + t.Fatalf("условие побега не должно было выполниться при стартовых ресурсах") + } + view := m.View() + if strings.Contains(view, T("story.hub.escape_option")) { + t.Errorf("пункт побега не должен был показываться до выполнения условия") + } +} + +func TestStoryEscapeRequiresAllThreeConditions(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + + // стресс высокий, но запасов не хватает — не должно сработать + m.story.Stress = 60 + if m.storyEscapeUnlocked() { + t.Errorf("побег не должен был разблокироваться без достаточных запасов мыла/верёвок, даже при высоком стрессе") + } + + // запасов хватает, но стресс низкий — тоже не должно сработать + m.story.Stress = 20 + m.story.Resources[StoryResTicTacToe] = 50 + m.story.Resources[StoryResSenet] = 50 + if m.storyEscapeUnlocked() { + t.Errorf("побег не должен был разблокироваться при низком стрессе, даже с достаточными запасами") + } + + // все три условия разом — теперь должно сработать + m.story.Stress = 60 + if !m.storyEscapeUnlocked() { + t.Errorf("побег должен был разблокироваться при выполнении всех трёх условий разом") + } +} + +func TestStoryEscapeStillLockedBelowStressThreshold(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + m.story.Resources[StoryResTicTacToe] = 50 + m.story.Resources[StoryResSenet] = 50 + m.story.Stress = 59 // чуть ниже порога, запасов уже достаточно + + if m.storyEscapeUnlocked() { + t.Errorf("побег не должен был разблокироваться при стрессе ниже 60%%, даже с достаточными запасами") + } +} + +func TestStoryEscapeOptionAppearsAndLeadsToAttemptScreen(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone // пункт побега скрыт во время знакомства + m.story.Resources[StoryResTicTacToe] = 50 + m.story.Resources[StoryResSenet] = 50 + m.story.Stress = 60 + m.state = stateStoryHub + m.storyHubCursor = len(storyHubOrder) // дополнительный пункт в конце списка + + if !m.storyEscapeUnlocked() { + t.Fatalf("условие побега должно было выполниться при 50/50/60") + } + view := m.View() + if !strings.Contains(view, T("story.hub.escape_option")) { + t.Errorf("пункт побега должен был появиться в хабе") + } + + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateStoryEscapeAttempt { + t.Fatalf("выбор пункта побега должен был вести на экран уговоров, получено %v", m.state) + } +} + +func TestStoryEscapeAttemptTakingPillResetsStressAndReturnsToHub(t *testing.T) { + withTempConfigDir(t) + m := AppModel{state: stateStoryEscapeAttempt, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + m.story.Stress = 80 + next, _ := m.Update(key("1")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("выбор таблетки должен был вернуть в хаб, получено %v", m.state) + } + if m.story.Stress != 0 { + t.Errorf("таблетка должна была сбросить стресс до 0, получено %d", m.story.Stress) + } +} + +func TestStoryEscapeAttemptEscReturnsToHubWithoutTakingPill(t *testing.T) { + m := AppModel{state: stateStoryEscapeAttempt, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + m.story.Stress = 80 + next, _ := m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("esc должен был вернуть в хаб, получено %v", m.state) + } + if m.story.Stress != 80 { + t.Errorf("esc не должен был трогать стресс, получено %d", m.story.Stress) + } +} + +func TestStoryEscapeAttemptLeavingGoesToEnding(t *testing.T) { + m := AppModel{state: stateStoryEscapeAttempt} + next, _ := m.Update(key("2")) + m = next.(AppModel) + if m.state != stateStoryEscapeEnding { + t.Errorf("выбор 'сбежать' должен был вести на экран концовки, получено %v", m.state) + } +} + +func TestStoryEscapeEndingReturnsToModeSelect(t *testing.T) { + m := AppModel{state: stateStoryEscapeEnding} + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateModeSelect { + t.Errorf("экран концовки должен был вести в меню режимов, получено %v", m.state) + } +} + +func TestFreshStoryStartsOnboardingWithTicTacToeOnly(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.state = stateStoryDifficulty + next, _ := m.Update(key("enter")) + m = next.(AppModel) + + if m.state != stateStoryHub { + t.Fatalf("ожидался прямой переход в хаб, получено %v", m.state) + } + if m.story.Onboarding != StoryOnboardingTicTacToeOnly { + t.Fatalf("ожидался этап знакомства TicTacToeOnly, получено %v", m.story.Onboarding) + } + visible := m.storyVisibleGames() + if len(visible) != 1 || visible[0] != StoryResTicTacToe { + t.Fatalf("на этапе знакомства должны были быть видны только крестики-нолики, получено %v", visible) + } + + view := m.View() + if strings.Contains(view, "/10") || strings.Contains(view, "Сложность:") { + t.Errorf("на этапе знакомства не должно быть чисел ресурсов или строки сложности, получено:\n%s", view) + } + if strings.Contains(view, T(storyGameDefs[StoryResCheckers].NameKey)) { + t.Errorf("шашки не должны быть видны на самом первом этапе знакомства") + } +} + +func TestOnboardingShowsGameNameNotResourceName(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + view := m.View() + if !strings.Contains(view, T("game.tictactoe.name")) { + t.Errorf("на этапе знакомства должно было показываться чистое название игры %q, получено:\n%s", T("game.tictactoe.name"), view) + } + if strings.Contains(view, T(storyResourceDefs[StoryResTicTacToe].NameKey)) { + t.Errorf("на этапе знакомства не должно было показываться название ресурса (%q) — оно ещё ничего не значит для игрока, получено:\n%s", T(storyResourceDefs[StoryResTicTacToe].NameKey), view) + } +} + +func TestThreeTicTacToeWinsUnlockCheckers(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + tm := NewTicTacToeModel(TicTacToeDifficultyEasy) + tm.game.Phase = TicTacToePhaseOver + tm.game.Result = &TicTacToeResult{Winner: tm.game.HumanMark} + tm.backToMenu = true + + for i := 0; i < 3; i++ { + resource := StoryResTicTacToe + m.storyActiveResource = &resource + m.activeGame = tm + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + } + + if m.story.Onboarding != StoryOnboardingWithCheckers { + t.Fatalf("после 3 побед ожидался переход на этап WithCheckers, получено %v", m.story.Onboarding) + } + if m.state != stateStoryHub { + t.Fatalf("ожидался возврат в хаб (не приветствие) — знакомство ещё не завершено, получено %v", m.state) + } + visible := m.storyVisibleGames() + if len(visible) != 2 { + t.Fatalf("ожидалось 2 видимые игры (крестики-нолики + шашки), получено %v", visible) + } +} + +func TestOneCheckersWinCompletesOnboardingAndShowsWelcome(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingWithCheckers + m.story.Resources[StoryResTicTacToe] = storyStartingAmount + StoryOnboardingTicTacToeWinsNeeded + m.state = stateStoryHub + + cm := NewCheckersModel(0, ColorChoiceA) + cm.game.Result = &CheckersResult{Winner: cm.human} + cm.backToMenu = true + + resource := StoryResCheckers + m.storyActiveResource = &resource + m.activeGame = cm + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + + if m.story.Onboarding != StoryOnboardingDone { + t.Fatalf("после победы в шашках знакомство должно было завершиться, получено %v", m.story.Onboarding) + } + if m.state != stateStoryWelcome { + t.Fatalf("ровно в момент завершения знакомства ожидалось приветствие, получено %v", m.state) + } + + next, _ = m.Update(key(" ")) // показать текст полностью (пропустить печать) + m = next.(AppModel) + next, _ = m.Update(key(" ")) // продолжить в хаб + m = next.(AppModel) + if m.state != stateStoryHub { + t.Fatalf("после приветствия ожидался переход в полный хаб, получено %v", m.state) + } + view := m.View() + if !strings.Contains(view, "Сложность:") { + t.Errorf("после завершения знакомства хаб должен был показывать обычный вид со строкой сложности, получено:\n%s", view) + } + visible := m.storyVisibleGames() + if len(visible) != len(storyHubOrder) { + t.Errorf("после завершения знакомства должны были стать видны все игры, получено %d из %d", len(visible), len(storyHubOrder)) + } +} + +func TestWelcomeScreenNeverShownAgainAfterOnboarding(t *testing.T) { + withTempConfigDir(t) + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Onboarding = StoryOnboardingDone + if err := SaveStoryModeState(s); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + next, _ = m.Update(key("down")) // курсор на "Режим истории" + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + + if m.state != stateStoryMorning { + t.Fatalf("при завершённом знакомстве и существующем сохранении ожидалось ежедневное приветствие, получено %v", m.state) + } +} + +func TestStoryRevealPartialTextPreservesParagraphs(t *testing.T) { + text := "Раз два три.\n\nЧетыре пять шесть семь." + // первые 3 слова — только первый абзац целиком, второй ещё не начат + got := storyRevealPartialText(text, 3) + if got != "Раз два три." { + t.Errorf("ожидался только первый абзац целиком, получено %q", got) + } + // 3+2=5 слов — первый абзац целиком плюс первые 2 слова второго + got = storyRevealPartialText(text, 5) + if got != "Раз два три.\n\nЧетыре пять" { + t.Errorf("ожидался первый абзац целиком плюс частичный второй, получено %q", got) + } + // все слова — весь текст целиком, без изменений + got = storyRevealPartialText(text, storyTotalWords(text)) + if got != text { + t.Errorf("при показе всех слов текст должен был совпасть с оригиналом, получено %q, ожидалось %q", got, text) + } +} + +func TestStoryRevealPartialTextZeroWordsIsEmpty(t *testing.T) { + if got := storyRevealPartialText("Раз два три", 0); got != "" { + t.Errorf("при 0 слов ожидалась пустая строка, получено %q", got) + } +} + +func TestStoryWelcomeTicksRevealWordsGradually(t *testing.T) { + withTempConfigDir(t) + m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 0} + total := storyTotalWords(T("story.welcome.text")) + + next, cmd := m.Update(storyWelcomeTickMsg{}) + m = next.(AppModel) + if m.storyWelcomeWordsShown != 1 { + t.Fatalf("после одного тика ожидалось показанное 1 слово, получено %d", m.storyWelcomeWordsShown) + } + if cmd == nil { + t.Errorf("должен был запланироваться следующий тик — текст ещё не показан целиком (всего слов: %d)", total) + } +} + +func TestStoryWelcomeTicksStopAtTotalWords(t *testing.T) { + m := AppModel{state: stateStoryWelcome} + total := storyTotalWords(T("story.welcome.text")) + m.storyWelcomeWordsShown = total - 1 + + next, cmd := m.Update(storyWelcomeTickMsg{}) + m = next.(AppModel) + if m.storyWelcomeWordsShown != total { + t.Fatalf("ожидалось, что счётчик достигнет total=%d, получено %d", total, m.storyWelcomeWordsShown) + } + if cmd != nil { + t.Errorf("после показа всего текста дальнейший тик планироваться не должен") + } +} + +func TestStoryWelcomeKeyPressSkipsTypingFirst(t *testing.T) { + m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 2} + total := storyTotalWords(T("story.welcome.text")) + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateStoryWelcome { + t.Fatalf("первое нажатие во время печати должно было остаться на экране приветствия, получено %v", m.state) + } + if m.storyWelcomeWordsShown != total { + t.Errorf("первое нажатие должно было сразу показать весь текст, получено %d из %d слов", m.storyWelcomeWordsShown, total) + } +} + +func TestStoryWelcomeKeyPressContinuesWhenFullyShown(t *testing.T) { + withTempConfigDir(t) + total := storyTotalWords(T("story.welcome.text")) + m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: total, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("нажатие после полного показа текста должно было продолжить в хаб, получено %v", m.state) + } +} + +func TestStoryWelcomeViewShowsTypingHintWhileIncomplete(t *testing.T) { + m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: 1} + view := m.viewStoryWelcome() + if !strings.Contains(view, T("story.welcome.hint.typing")) { + t.Errorf("во время печати должна была показываться подсказка 'показать сразу', получено:\n%s", view) + } +} + +func TestStoryWelcomeViewShowsContinueHintWhenComplete(t *testing.T) { + total := storyTotalWords(T("story.welcome.text")) + m := AppModel{state: stateStoryWelcome, storyWelcomeWordsShown: total} + view := m.viewStoryWelcome() + if !strings.Contains(view, T("story.welcome.hint")) || strings.Contains(view, T("story.welcome.hint.typing")) { + t.Errorf("после полного показа текста должна была показываться обычная подсказка 'продолжить', получено:\n%s", view) + } +} + +func TestStoryHubShowsVitalsWhenOnboardingDone(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + m.story.Stress = 45 + m.story.Poisoning = 10 + m.story.ToxinLevel = 5 + m.state = stateStoryHub + + view := m.View() + if !strings.Contains(view, "45") || !strings.Contains(view, "10") || !strings.Contains(view, "5") { + t.Errorf("хаб должен был показывать значения стресса/отравления/токсина, получено:\n%s", view) + } +} + +func TestStoryHubHidesVitalsDuringOnboarding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + view := m.View() + if strings.Contains(view, T("story.hub.difficulty_label")) { + t.Errorf("на этапе знакомства показатели выживания не должны показываться") + } +} + +func TestStoryHubUKeyOpensUseItemScreen(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + m.state = stateStoryHub + + next, _ := m.Update(key("u")) + m = next.(AppModel) + if m.state != stateStoryUseItem { + t.Fatalf("клавиша u должна была открыть экран использования, получено %v", m.state) + } +} + +func TestStoryHubUKeyDisabledDuringOnboarding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + next, _ := m.Update(key("u")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("клавиша u не должна была работать на этапе знакомства, получено %v", m.state) + } +} + +func TestStoryUseItemSpendsResourceAndAppliesEffect(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Stress = 40 + m.story.Resources[StoryResKlondike] = 5 + m.state = stateStoryUseItem + + next, _ := m.Update(key("1")) // антидепрессант — первый пункт + m = next.(AppModel) + if m.story.Stress != 20 { + t.Errorf("ожидался стресс 20 после траты антидепрессанта, получено %d", m.story.Stress) + } + if m.story.Resources[StoryResKlondike] != 4 { + t.Errorf("ожидалось списание 1 антидепрессанта, получено %d", m.story.Resources[StoryResKlondike]) + } + if m.state != stateStoryUseItem { + t.Errorf("после использования должны были остаться на этом же экране, получено %v", m.state) + } +} + +func TestStoryUseItemEscReturnsToHub(t *testing.T) { + m := AppModel{state: stateStoryUseItem} + next, _ := m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("esc на экране использования должен был вернуть в хаб, получено %v", m.state) + } +} + +func TestStoryGreetingForDaysMapping(t *testing.T) { + cases := []struct { + days int + want storyGreetingKind + }{ + {0, storyGreetingPlayingAgain}, + {1, storyGreetingWokeUp}, + {2, storyGreetingWorried}, + {5, storyGreetingWorried}, + } + for _, c := range cases { + if got := storyGreetingForDays(c.days); got != c.want { + t.Errorf("для %d дней ожидалось %v, получено %v", c.days, c.want, got) + } + } +} + +func TestRandomStoryPostGameFlavorReturnsValidKey(t *testing.T) { + valid := make(map[string]bool) + for _, k := range storyPostGameFlavorKeys { + valid[k] = true + } + for i := 0; i < 30; i++ { + got := randomStoryPostGameFlavor() + if !valid[got] { + t.Fatalf("randomStoryPostGameFlavor вернул неизвестный ключ %q", got) + } + } +} + +func TestPostGameFlavorPoolHasTwelveDistinctPhrases(t *testing.T) { + if len(storyPostGameFlavorKeys) != 12 { + t.Fatalf("ожидалось 12 фраз в пуле, получено %d", len(storyPostGameFlavorKeys)) + } + seen := make(map[string]bool) + for _, k := range storyPostGameFlavorKeys { + text := T(k) + if text == "" || text == k { + t.Errorf("ключ %q не имеет перевода", k) + } + if seen[text] { + t.Errorf("повторяющийся текст фразы: %q", text) + } + seen[text] = true + } +} + +func TestPostGameFlavorShownAfterReturningFromGame(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + + tm := NewTicTacToeModel(TicTacToeDifficultyEasy) + tm.game.Phase = TicTacToePhaseOver + loser := TicTacToeX + if tm.game.HumanMark == TicTacToeX { + loser = TicTacToeO + } + tm.game.Result = &TicTacToeResult{Winner: loser} + tm.backToMenu = true + + resource := StoryResTicTacToe + m.storyActiveResource = &resource + m.activeGame = tm + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + + if m.storyPostGameFlavor == "" { + t.Fatalf("после возврата из партии ожидалась выбранная фраза-наблюдение") + } + view := m.View() + if !strings.Contains(view, T(m.storyPostGameFlavor)) { + t.Errorf("хаб должен был показывать выбранную фразу, получено:\n%s", view) + } +} + +func TestStoryHubNKeyShowsResetConfirmation(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + m.state = stateStoryHub + + next, _ := m.Update(key("N")) + m = next.(AppModel) + if !m.storyConfirmingReset { + t.Fatalf("клавиша N должна была включить режим подтверждения сброса") + } + view := m.View() + if !strings.Contains(view, T("story.hub.reset_confirm")) { + t.Errorf("ожидался текст подтверждения сброса, получено:\n%s", view) + } +} + +func TestStoryHubResetConfirmYDeletesSaveAndGoesToDifficulty(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyHard, time.Now()) + m.story.Resources[StoryResChess] = 50 + if err := SaveStoryModeState(m.story); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + m.state = stateStoryHub + m.storyConfirmingReset = true + + next, _ := m.Update(key("y")) + m = next.(AppModel) + if m.state != stateStoryDifficulty { + t.Fatalf("после подтверждения ожидался переход на выбор сложности, получено %v", m.state) + } + if m.storyConfirmingReset { + t.Errorf("режим подтверждения должен был сброситься") + } + if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { + t.Errorf("сохранение должно было быть удалено, получено err=%v", err) + } +} + +func TestStoryHubResetConfirmNCancelsAndKeepsProgress(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Resources[StoryResChess] = 77 + m.state = stateStoryHub + m.storyConfirmingReset = true + + next, _ := m.Update(key("n")) + m = next.(AppModel) + if m.storyConfirmingReset { + t.Errorf("отмена должна была выключить режим подтверждения") + } + if m.state != stateStoryHub { + t.Errorf("после отмены ожидался возврат в хаб, получено %v", m.state) + } + if m.story.Resources[StoryResChess] != 77 { + t.Errorf("прогресс не должен был пострадать при отмене, получено %d", m.story.Resources[StoryResChess]) + } +} + +func TestStoryHubResetConfirmEscCancels(t *testing.T) { + m := AppModel{state: stateStoryHub, storyConfirmingReset: true, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + next, _ := m.Update(key("esc")) + m = next.(AppModel) + if m.storyConfirmingReset { + t.Errorf("esc должен был отменить подтверждение сброса") + } +} + +func TestStoryHubResetAvailableDuringOnboarding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) // по умолчанию этап знакомства + m.state = stateStoryHub + + next, _ := m.Update(key("N")) + m = next.(AppModel) + if !m.storyConfirmingReset { + t.Errorf("сброс должен быть доступен и во время знакомства, получено storyConfirmingReset=%v", m.storyConfirmingReset) + } +} + +func TestAdvanceStoryEventQueueOrdering(t *testing.T) { + m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + + m.story.PendingNeurotoxinAlert = true + m.story.PendingDoorKnockAlert = true + if got := m.advanceStoryEventQueue(); got != stateStoryNeurotoxinAlert { + t.Errorf("при обоих ожидающих событиях сначала должен был идти выброс, получено %v", got) + } + + m.story.PendingNeurotoxinAlert = false + if got := m.advanceStoryEventQueue(); got != stateStoryDoorKnockAlert { + t.Errorf("после выброса должен был идти стук, получено %v", got) + } + + m.story.PendingDoorKnockAlert = false + if got := m.advanceStoryEventQueue(); got != stateStoryHub { + t.Errorf("без ожидающих событий должен был идти хаб, получено %v", got) + } +} + +func TestNeurotoxinAlertDismissAdvancesToKnockThenHub(t *testing.T) { + withTempConfigDir(t) + m := AppModel{state: stateStoryNeurotoxinAlert, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + m.story.PendingNeurotoxinAlert = true + m.story.PendingDoorKnockAlert = true + m.story.ToxinLevel = 45 + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.story.PendingNeurotoxinAlert { + t.Errorf("после показа флаг выброса должен был сброситься") + } + if m.state != stateStoryDoorKnockAlert { + t.Fatalf("ожидался переход к экрану стука в дверь, получено %v", m.state) + } + + next, _ = m.Update(key(" ")) + m = next.(AppModel) + if m.story.PendingDoorKnockAlert { + t.Errorf("после показа флаг стука должен был сброситься") + } + if m.state != stateStoryHub { + t.Errorf("после обоих уведомлений ожидался переход в хаб, получено %v", m.state) + } +} + +func TestNeurotoxinAlertViewShowsToxinLevel(t *testing.T) { + m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + m.story.ToxinLevel = 55 + view := m.viewStoryNeurotoxinAlert() + if !strings.Contains(view, "55") { + t.Errorf("экран выброса должен был показывать текущий уровень токсина, получено:\n%s", view) + } +} + +func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) { + withTempConfigDir(t) + m := AppModel{state: stateStoryDoorKnockAlert, story: NewStoryModeState(StoryDifficultyEasy, time.Now())} + m.story.PendingDoorKnockAlert = true + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.story.PendingDoorKnockAlert { + t.Errorf("флаг стука должен был сброситься после показа") + } + if m.state != stateStoryHub { + t.Errorf("ожидался переход в хаб, получено %v", m.state) + } +} \ No newline at end of file diff --git a/story_game.go b/story_game.go new file mode 100644 index 0000000..ae2d723 --- /dev/null +++ b/story_game.go @@ -0,0 +1,519 @@ +package main + +import ( + "math/rand" + "time" +) + +// Режим истории: набор ресурсов, по одному на каждую "обычную" +// (с чётким исходом партии) игру коллекции. Победа в игре +// начисляет +1 её ресурса — но не более StoryDailyGrantsLimit раз в +// реальные сутки (скользящие 24 часа, как в Трупных батончиках — +// без возни с календарём и часовыми поясами): играть можно сколько +// угодно, просто сверх лимита выдача ресурса в этот день уже не +// происходит. Как только ресурс достигает своей цели, он +// "запирается в сейф" — суточный лимит на него больше не тратится +// (см. AwardResource), и игра остаётся доступной, а ресурс +// продолжает расти дальше без верхнего предела. +// +// Трупные батончики и Странник в сторимод не включены вовсе: первая +// — единственная игра коллекции без дискретной победы или +// поражения за одну партию, второй — открытый мир без единого +// чёткого условия "победы" за одну партию. Обе доступны как обычно +// в режиме "просто игры", но никак не участвуют в накоплении +// ресурсов сторимода. + +// StoryResourceID — идентификатор одного ресурса сторимода, +// привязанного к конкретной игре коллекции. +type StoryResourceID int + +const ( + StoryResTonk StoryResourceID = iota + StoryResDurak + StoryResCheckers + StoryResNardy + StoryResBlackjack + StoryResChess + StoryResMinesweeper + StoryResGo + StoryResUr + StoryResSenet + StoryResOneOhOne + StoryResThousand + StoryResKlondike + StoryResTicTacToe + StoryResPoker + + storyResourceCount // сколько всего ресурсов — не отдельный ресурс, а счётчик +) + +// Цели по каждому ресурсу — отдельные именованные константы, а не +// одно общее число: у каждой игры со временем может появиться своя +// собственная цифра. +const ( + StoryTargetTonk = 100 + StoryTargetDurak = 100 + StoryTargetCheckers = 100 + StoryTargetNardy = 100 + StoryTargetBlackjack = 100 + StoryTargetChess = 100 + StoryTargetMinesweeper = 100 + StoryTargetGo = 100 + StoryTargetUr = 100 + StoryTargetSenet = 100 + StoryTargetOneOhOne = 100 + StoryTargetThousand = 100 + StoryTargetKlondike = 100 + StoryTargetTicTacToe = 10 + StoryTargetPoker = 100 + + // storyStartingAmount — стартовое количество каждого ресурса в + // начале сторимода. + storyStartingAmount = 3 +) + +// storyResourceDef — статическое описание одного ресурса: ключ +// перевода названия и цель. +type storyResourceDef struct { + NameKey string + Target int +} + +var storyResourceDefs = [storyResourceCount]storyResourceDef{ + StoryResTonk: {NameKey: "story.resource.tonk", Target: StoryTargetTonk}, + StoryResDurak: {NameKey: "story.resource.durak", Target: StoryTargetDurak}, + StoryResCheckers: {NameKey: "story.resource.checkers", Target: StoryTargetCheckers}, + StoryResNardy: {NameKey: "story.resource.nardy", Target: StoryTargetNardy}, + StoryResBlackjack: {NameKey: "story.resource.blackjack", Target: StoryTargetBlackjack}, + StoryResChess: {NameKey: "story.resource.chess", Target: StoryTargetChess}, + StoryResMinesweeper: {NameKey: "story.resource.minesweeper", Target: StoryTargetMinesweeper}, + StoryResGo: {NameKey: "story.resource.go", Target: StoryTargetGo}, + StoryResUr: {NameKey: "story.resource.ur", Target: StoryTargetUr}, + StoryResSenet: {NameKey: "story.resource.senet", Target: StoryTargetSenet}, + StoryResOneOhOne: {NameKey: "story.resource.oneohone", Target: StoryTargetOneOhOne}, + StoryResThousand: {NameKey: "story.resource.thousand", Target: StoryTargetThousand}, + StoryResKlondike: {NameKey: "story.resource.klondike", Target: StoryTargetKlondike}, + StoryResTicTacToe: {NameKey: "story.resource.tictactoe", Target: StoryTargetTicTacToe}, + StoryResPoker: {NameKey: "story.resource.poker", Target: StoryTargetPoker}, +} + +// StoryDifficulty — уровень сложности, выбираемый один раз в начале +// сторимода и определяющий, какой уровень бота используется в +// каждой из игр, где такой выбор вообще есть. +type StoryDifficulty int + +const ( + StoryDifficultyEasy StoryDifficulty = iota + StoryDifficultyMedium + StoryDifficultyHard +) + +// StoryOnboardingStage — этап знакомства перед тем, как открывается +// весь лагерь целиком со всеми 15 играми и видимыми числами +// ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется +// из их текущих значений), потому что суточный расход мог бы +// откатить прогресс знакомства назад — переход всегда только вперёд. +type StoryOnboardingStage int + +const ( + // StoryOnboardingTicTacToeOnly — доступны только крестики-нолики, + // без чисел ресурсов и прочего антуража обычного лагеря. + StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota + // StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел. + StoryOnboardingWithCheckers + // StoryOnboardingDone — обычный режим, весь лагерь как обычно. + StoryOnboardingDone +) + +// StoryOnboardingTicTacToeWinsNeeded — сколько побед/ничьих в +// крестики-нолики нужно для открытия шашек. +const StoryOnboardingTicTacToeWinsNeeded = 3 + +// StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно, +// чтобы знакомство завершилось и открылся весь лагерь. +const StoryOnboardingCheckersWinsNeeded = 1 + +// StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может +// произойти начисление ресурса за победу (независимо от того, +// сколько партий реально сыграно) — обходной канал доступа к складу +// ограничен, чтобы не сжечь оставшиеся схемы. +const StoryDailyGrantsLimit = 3 + +// StoryStressFloor — стресс по умолчанию не опускается ниже этого +// значения. +const StoryStressFloor = 20 + +// StoryModeState — всё состояние партии сторимода. +type StoryModeState struct { + Difficulty StoryDifficulty + + // Onboarding — этап знакомства (см. StoryOnboardingStage). Новая + // партия всегда начинается с StoryOnboardingTicTacToeOnly. + Onboarding StoryOnboardingStage + + Resources [storyResourceCount]int + Locked [storyResourceCount]bool + + // KlondikeWinsToday — сколько партий Косынки выиграно именно + // сегодня (сбрасывается каждые прожитые виртуальные сутки, см. + // Tick). У Косынки особое правило — 3 победы за один день, а не + // серия побед подряд или общий счётчик раундов. + KlondikeWinsToday int + + // DailyGrantsUsed — сколько раз сегодня уже произошло начисление + // ресурса за победу (см. StoryDailyGrantsLimit и AwardResource). + DailyGrantsUsed int + + // Стресс, отравление и содержание токсина в воздухе — проценты + // от 0 до 100. + Stress int + Poisoning int + ToxinLevel int + + // FilterUsedToday — фильтр для воздуха израсходован именно + // сегодня (сбрасывается каждые новые сутки, см. Tick). + // DaysSinceFilterUsed — сколько суток подряд фильтр НЕ + // расходовался; чем больше, тем выше вероятность выброса + // нейротоксина за очередные сутки (см. storyNeurotoxinProbability). + // Сбрасывается в 0 только при реальном расходе фильтра, не при + // самом выбросе — если фильтр так и не поменяли, риск остаётся + // на максимуме и дальше. + FilterUsedToday bool + DaysSinceFilterUsed int + + // DaysSinceDoorKnock — сколько суток подряд не было стука в + // дверь; чем больше, тем выше вероятность за очередные сутки (см. + // storyDoorKnockProbability). Сбрасывается в 0 при самом стуке. + DaysSinceDoorKnock int + + // PendingNeurotoxinAlert/PendingDoorKnockAlert — хотя бы одно + // такое событие произошло с последнего показа соответствующего + // экрана-уведомления (см. stateStoryNeurotoxinAlert/ + // stateStoryDoorKnockAlert) — показываются только при входе через + // меню режимов, по очереди, после приветствия дня. + PendingNeurotoxinAlert bool + PendingDoorKnockAlert bool + + // Очистка воздуха, запущенная батарейкой: содержание токсина + // плавно снижается к нулю в течение часа с момента запуска. + AirPurificationActive bool + AirPurificationStart time.Time + AirPurificationFromLevel int + + // Восстановление стресса после антидепрессанта: антидепрессант + // может опустить стресс ниже обычного пола (StoryStressFloor), + // вплоть до 0, но затем стресс плавно возвращается к полу в + // течение часа с момента приёма. + StressRecoveryActive bool + StressRecoveryStart time.Time + StressRecoveryFromLevel int + + // LastTick — момент последнего учтённого перехода виртуальных + // суток (скользящие 24 часа, не календарные сутки) — используется + // для сброса суточных счётчиков (KlondikeWinsToday, + // DailyGrantsUsed, FilterUsedToday) и розыгрыша суточных событий + // (выброс нейротоксина, стук в дверь). + LastTick time.Time +} + +// NewStoryModeState начинает новый сторимод с указанной сложностью: +// все ресурсы стартуют с storyStartingAmount, ничего не заперто, +// стресс 20%, отравление и токсин — 0%. +func NewStoryModeState(difficulty StoryDifficulty, now time.Time) *StoryModeState { + s := &StoryModeState{Difficulty: difficulty, LastTick: now, Stress: StoryStressFloor} + for i := range s.Resources { + s.Resources[i] = storyStartingAmount + } + return s +} + +// Tick сбрасывает суточные счётчики (KlondikeWinsToday, +// DailyGrantsUsed) за каждые полные истёкшие "виртуальные сутки" +// (скользящие 24 часа с последнего учтённого тика). Возвращает, +// сколько таких суток было учтено (0, если ещё не прошли полные +// сутки) — вызывающий код использует это, чтобы понять, нужно ли +// показать утренний экран. Ресурсы сами по себе больше не +// расходуются со временем. +// Tick сбрасывает суточные счётчики (KlondikeWinsToday, +// DailyGrantsUsed, FilterUsedToday) и разыгрывает суточные события +// (выброс нейротоксина, стук в дверь — см. processDayEvents) за +// каждые полные истёкшие "виртуальные сутки" (скользящие 24 часа с +// последнего учтённого тика), каждый день по отдельности. Возвращает, +// сколько таких суток было учтено (0, если ещё не прошли полные +// сутки) — вызывающий код использует это, чтобы понять, нужно ли +// показать утренний экран. +func (s *StoryModeState) Tick(now time.Time) int { + elapsed := now.Sub(s.LastTick) + days := int(elapsed / (24 * time.Hour)) + if days <= 0 { + return 0 + } + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < days; i++ { + s.processDayEvents(rnd) + } + s.KlondikeWinsToday = 0 + s.DailyGrantsUsed = 0 + s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour) + return days +} + +// storyNeurotoxinProbability — вероятность выброса нейротоксина за +// сутки в зависимости от того, сколько суток подряд фильтр для +// воздуха не менялся. 0 суток (фильтр менялся сегодня) — выброса не +// может быть вовсе. +func storyNeurotoxinProbability(daysSinceFilter int) float64 { + switch { + case daysSinceFilter >= 3: + return 1.0 + case daysSinceFilter == 2: + return 0.75 + case daysSinceFilter >= 1: + return 0.5 + default: + return 0 + } +} + +// storyDoorKnockProbability — вероятность стука в дверь за сутки в +// зависимости от того, сколько суток подряд его не было. Ровно на +// третьи сутки (и далее) — гарантированно. +func storyDoorKnockProbability(daysSinceKnock int) float64 { + switch { + case daysSinceKnock >= 3: + return 1.0 + case daysSinceKnock == 2: + return 0.5 + case daysSinceKnock >= 1: + return 0.1 + default: + return 0 + } +} + +// processDayEvents разыгрывает события ровно одних суток: выброс +// нейротоксина (зависит от того, менялся ли фильтр в эти сутки) и +// стук в дверь (не зависит ни от чего, кроме того, сколько суток его +// не было). Может произойти и то, и другое в один и тот же день. +func (s *StoryModeState) processDayEvents(rnd *rand.Rand) { + if s.FilterUsedToday { + s.DaysSinceFilterUsed = 0 + } else { + s.DaysSinceFilterUsed++ + } + if rnd.Float64() < storyNeurotoxinProbability(s.DaysSinceFilterUsed) { + power := 10 + rnd.Intn(61) // 10..70 включительно + s.ToxinLevel += power + if s.ToxinLevel > 100 { + s.ToxinLevel = 100 + } + s.PendingNeurotoxinAlert = true + } + s.FilterUsedToday = false + + s.DaysSinceDoorKnock++ + if rnd.Float64() < storyDoorKnockProbability(s.DaysSinceDoorKnock) { + s.Stress += 20 + if s.Stress > 100 { + s.Stress = 100 + } + s.PendingDoorKnockAlert = true + s.DaysSinceDoorKnock = 0 + } +} + +// RecordWin начисляет +1 ресурса id безусловно, без учёта суточного +// лимита — используется только изнутри AwardResource. Если это +// начисление как раз доводит ресурс до цели, он тут же запирается в +// сейф, что не блокирует ни саму игру, ни дальнейшее накопление: +// после фиксации игра остаётся доступной, и ресурс просто продолжает +// расти дальше без верхнего предела. +func (s *StoryModeState) RecordWin(id StoryResourceID) { + s.Resources[id]++ + if !s.Locked[id] && s.Resources[id] >= storyResourceDefs[id].Target { + s.Locked[id] = true + } +} + +// AwardResource пытается начислить +1 ресурса id за победу, но +// только если дневной лимит начислений (StoryDailyGrantsLimit) ещё +// не исчерпан за сегодня. Возвращает true, если начисление +// действительно произошло. У Косынки помимо этого лимита есть ещё +// собственное правило "3 победы за один день" — вызывающий код +// (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде +// чем вызвать AwardResource. +func (s *StoryModeState) AwardResource(id StoryResourceID) bool { + if s.DailyGrantsUsed >= StoryDailyGrantsLimit { + return false + } + s.RecordWin(id) + s.DailyGrantsUsed++ + return true +} + +// spendResource списывает amount единиц ресурса id, если их +// достаточно. Если после списания количество опускается ниже цели, +// ресурс перестаёт считаться запертым в сейфе — трата действительно +// расходует накопленное, а не просто "тратит излишек". +func (s *StoryModeState) spendResource(id StoryResourceID, amount int) bool { + if s.Resources[id] < amount { + return false + } + s.Resources[id] -= amount + if s.Resources[id] < storyResourceDefs[id].Target { + s.Locked[id] = false + } + return true +} + +// SpendAntidepressant тратит 1 антидепрессант (ресурс Косынки) и +// снижает стресс на 20%, не опускаясь ниже StoryStressFloor. +// SpendAntidepressant тратит 1 антидепрессант (ресурс Косынки) и +// снижает стресс на 20%, на этот раз не ограничиваясь обычным полом +// StoryStressFloor — стресс может опуститься вплоть до 0%. Если +// после этого стресс оказался ниже обычного пола, запускается +// часовое восстановление обратно к полу (см. ApplyStressRecovery). +func (s *StoryModeState) SpendAntidepressant(now time.Time) bool { + if !s.spendResource(StoryResKlondike, 1) { + return false + } + // сначала актуализируем уже идущее восстановление (если оно + // есть), чтобы вычитать 20 из настоящего текущего значения, а не + // из устаревшего + s.ApplyStressRecovery(now) + s.Stress -= 20 + if s.Stress < 0 { + s.Stress = 0 + } + if s.Stress < StoryStressFloor { + s.StressRecoveryActive = true + s.StressRecoveryStart = now + s.StressRecoveryFromLevel = s.Stress + } else { + s.StressRecoveryActive = false + } + return true +} + +// ApplyStressRecovery проверяет, идёт ли сейчас восстановление +// стресса после антидепрессанта, и если да — плавно поднимает +// стресс от StressRecoveryFromLevel к StoryStressFloor, пропорционально +// прошедшему времени, вплоть до полного завершения через час. +func (s *StoryModeState) ApplyStressRecovery(now time.Time) { + if !s.StressRecoveryActive { + return + } + elapsed := now.Sub(s.StressRecoveryStart) + if elapsed >= time.Hour { + s.Stress = StoryStressFloor + s.StressRecoveryActive = false + return + } + frac := float64(elapsed) / float64(time.Hour) + s.Stress = s.StressRecoveryFromLevel + int(float64(StoryStressFloor-s.StressRecoveryFromLevel)*frac) + if s.Stress > StoryStressFloor { + s.Stress = StoryStressFloor + } +} + +// SpendAntidote тратит 1 антидот (ресурс Сапёра) и снижает +// отравление на 20%, не опускаясь ниже нуля. +func (s *StoryModeState) SpendAntidote() bool { + if !s.spendResource(StoryResMinesweeper, 1) { + return false + } + s.Poisoning -= 20 + if s.Poisoning < 0 { + s.Poisoning = 0 + } + return true +} + +// SpendAirFilter тратит 1 фильтр для воздуха (ресурс Го) — отмечает +// фильтр использованным сегодня. При обработке перехода суток (см. +// Tick) это защитит от выброса нейротоксина за сегодняшний день и +// сбросит счётчик дней без замены (DaysSinceFilterUsed) в 0. +func (s *StoryModeState) SpendAirFilter() bool { + if !s.spendResource(StoryResGo, 1) { + return false + } + s.FilterUsedToday = true + return true +} + +// SpendBattery тратит 1 батарейку (ресурс 101) и запускает очистку +// воздуха: содержание токсина плавно снижается к нулю в течение часа +// с этого момента (см. ApplyAirPurification). +func (s *StoryModeState) SpendBattery(now time.Time) bool { + if !s.spendResource(StoryResOneOhOne, 1) { + return false + } + s.AirPurificationActive = true + s.AirPurificationStart = now + s.AirPurificationFromLevel = s.ToxinLevel + return true +} + +// ApplyAirPurification проверяет, идёт ли сейчас запущенная батарейкой +// очистка воздуха, и если да — снижает содержание токсина +// пропорционально прошедшему времени, вплоть до полного завершения +// (токсин = 0) через час с момента запуска. +func (s *StoryModeState) ApplyAirPurification(now time.Time) { + if !s.AirPurificationActive { + return + } + elapsed := now.Sub(s.AirPurificationStart) + if elapsed >= time.Hour { + s.ToxinLevel = 0 + s.AirPurificationActive = false + return + } + frac := float64(elapsed) / float64(time.Hour) + s.ToxinLevel = s.AirPurificationFromLevel - int(float64(s.AirPurificationFromLevel)*frac) + if s.ToxinLevel < 0 { + s.ToxinLevel = 0 + } +} + +// AdvanceOnboarding проверяет, не пора ли перейти на следующий этап +// знакомства, исходя из текущих значений ресурсов крестиков-ноликов +// и шашек. Переход всегда только вперёд (см. StoryOnboardingStage) — +// вызывать безопасно после любой партии, не только тех двух игр. +// Возвращает true, если именно этим вызовом знакомство полностью +// завершилось (переход в StoryOnboardingDone) — по этому сигналу +// вызывающий код показывает приветствие в первый и единственный раз. +func (s *StoryModeState) AdvanceOnboarding() (justCompleted bool) { + switch s.Onboarding { + case StoryOnboardingTicTacToeOnly: + // ресурсы стартуют не с нуля, а с storyStartingAmount — порог + // считаем от стартового значения, иначе условие "3 победы" + // выполнилось бы сразу, без единой реально сыгранной партии + if s.Resources[StoryResTicTacToe] >= storyStartingAmount+StoryOnboardingTicTacToeWinsNeeded { + s.Onboarding = StoryOnboardingWithCheckers + } + case StoryOnboardingWithCheckers: + if s.Resources[StoryResCheckers] >= storyStartingAmount+StoryOnboardingCheckersWinsNeeded { + s.Onboarding = StoryOnboardingDone + return true + } + } + return false +} + +// AllResourcesLocked проверяет, заперты ли уже все ресурсы сторимода +// (все игры, кроме Странника и Трупных батончиков, которые в +// сторимоде не участвуют вовсе). +func (s *StoryModeState) AllResourcesLocked() bool { + for _, locked := range s.Locked { + if !locked { + return false + } + } + return true +} + +// Complete проверяет полное завершение сторимода: заперты все +// ресурсы. +func (s *StoryModeState) Complete() bool { + return s.AllResourcesLocked() +} diff --git a/story_game_test.go b/story_game_test.go new file mode 100644 index 0000000..6d74a10 --- /dev/null +++ b/story_game_test.go @@ -0,0 +1,506 @@ +package main + +import ( + "math/rand" + "testing" + "time" +) + +func TestNewStoryModeStateStartsWithThreeOfEach(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyMedium, now) + for i := 0; i < int(storyResourceCount); i++ { + if s.Resources[i] != storyStartingAmount { + t.Errorf("ресурс %d: ожидалось стартовое количество %d, получено %d", i, storyStartingAmount, s.Resources[i]) + } + if s.Locked[i] { + t.Errorf("ресурс %d: ничего не должно быть заперто в начале", i) + } + } +} + +func TestRecordWinIncrementsResource(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.RecordWin(StoryResTonk) + if s.Resources[StoryResTonk] != storyStartingAmount+1 { + t.Errorf("ожидалось увеличение ресурса Тонка на 1, получено %d", s.Resources[StoryResTonk]) + } + if s.Resources[StoryResDurak] != storyStartingAmount { + t.Errorf("ресурс Дурака не должен был измениться, получено %d", s.Resources[StoryResDurak]) + } +} + +func TestRecordWinLocksResourceAtTarget(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResCheckers] = StoryTargetCheckers - 1 + s.RecordWin(StoryResCheckers) + if s.Resources[StoryResCheckers] != StoryTargetCheckers { + t.Errorf("ожидалось достижение цели %d, получено %d", StoryTargetCheckers, s.Resources[StoryResCheckers]) + } + if !s.Locked[StoryResCheckers] { + t.Errorf("ресурс должен был запереться в сейф по достижении цели") + } +} + +func TestRecordWinOnLockedResourceKeepsAccumulating(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResChess] = StoryTargetChess + s.Locked[StoryResChess] = true + s.RecordWin(StoryResChess) + if s.Resources[StoryResChess] != StoryTargetChess+1 { + t.Errorf("после фиксации ресурс должен был продолжить копиться без ограничения, получено %d", s.Resources[StoryResChess]) + } + if !s.Locked[StoryResChess] { + t.Errorf("ресурс должен был остаться зафиксированным") + } +} + +func TestTickNoLongerDecaysResources(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Tick(now.Add(10 * 24 * time.Hour)) // 10 суток — раньше это списало бы 10 + if s.Resources[StoryResTonk] != storyStartingAmount { + t.Errorf("ресурсы больше не должны расходоваться со временем, получено %d, ожидалось %d", s.Resources[StoryResTonk], storyStartingAmount) + } +} + +func TestTickResetsDailyCountersAfterAFullDay(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.KlondikeWinsToday = 2 + s.DailyGrantsUsed = 3 + + s.Tick(now.Add(25 * time.Hour)) // чуть больше суток + + if s.KlondikeWinsToday != 0 { + t.Errorf("счётчик побед Косынки за день должен был сброситься, получено %d", s.KlondikeWinsToday) + } + if s.DailyGrantsUsed != 0 { + t.Errorf("счётчик суточных начислений должен был сброситься, получено %d", s.DailyGrantsUsed) + } +} + +func TestTickDoesNothingBeforeAFullDay(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Tick(now.Add(23 * time.Hour)) + if s.Resources[StoryResTonk] != storyStartingAmount { + t.Errorf("до истечения полных суток расход не должен был случиться, получено %d", s.Resources[StoryResTonk]) + } +} + +func TestTickAccumulatesMultipleDaysAtOnce(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.DailyGrantsUsed = 3 + days := s.Tick(now.Add(72 * time.Hour)) // ровно 3 суток + if days != 3 { + t.Errorf("ожидалось 3 учтённых суток, получено %d", days) + } + if s.DailyGrantsUsed != 0 { + t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed) + } +} + +func TestAllResourcesLockedFalseUntilEveryOneIsLocked(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if s.AllResourcesLocked() { + t.Fatalf("в начале не должно быть уже всё заперто") + } + for i := 0; i < int(storyResourceCount); i++ { + s.Locked[i] = true + } + if !s.AllResourcesLocked() { + t.Errorf("после запирания всех ресурсов AllResourcesLocked должен был вернуть true") + } +} + +func TestCompleteMatchesAllResourcesLocked(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if s.Complete() { + t.Fatalf("сторимод не должен считаться завершённым в начале") + } + for i := 0; i < int(storyResourceCount); i++ { + s.Locked[i] = true + } + if !s.Complete() { + t.Errorf("сторимод должен был считаться завершённым после запирания всех ресурсов") + } +} + +func TestNewStoryModeStateDefaultVitals(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if s.Stress != StoryStressFloor { + t.Errorf("ожидался стартовый стресс %d, получено %d", StoryStressFloor, s.Stress) + } + if s.Poisoning != 0 { + t.Errorf("ожидалось стартовое отравление 0, получено %d", s.Poisoning) + } + if s.ToxinLevel != 0 { + t.Errorf("ожидался стартовый токсин 0, получено %d", s.ToxinLevel) + } +} + +func TestAwardResourceRespectsDailyLimit(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + for i := 0; i < StoryDailyGrantsLimit; i++ { + if !s.AwardResource(StoryResChess) { + t.Fatalf("начисление %d должно было пройти (лимит %d)", i+1, StoryDailyGrantsLimit) + } + } + if s.AwardResource(StoryResChess) { + t.Errorf("начисление сверх дневного лимита не должно было пройти") + } + if s.Resources[StoryResChess] != storyStartingAmount+StoryDailyGrantsLimit { + t.Errorf("ожидалось %d очков ресурса, получено %d", storyStartingAmount+StoryDailyGrantsLimit, s.Resources[StoryResChess]) + } +} + +func TestAwardResourceLimitResetsAfterADay(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + for i := 0; i < StoryDailyGrantsLimit; i++ { + s.AwardResource(StoryResChess) + } + s.Tick(now.Add(25 * time.Hour)) + if !s.AwardResource(StoryResChess) { + t.Errorf("после сброса суточного счётчика начисление снова должно было пройти") + } +} + +func TestSpendAntidepressantCanGoBelowFloor(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Stress = 35 + s.Resources[StoryResKlondike] = 5 + if !s.SpendAntidepressant(now) { + t.Fatalf("трата антидепрессанта должна была пройти") + } + if s.Stress != 15 { + t.Errorf("ожидался стресс 15 (35-20, ниже обычного пола), получено %d", s.Stress) + } + if s.Resources[StoryResKlondike] != 4 { + t.Errorf("ожидалось списание 1 антидепрессанта, получено %d осталось", s.Resources[StoryResKlondike]) + } + if !s.StressRecoveryActive { + t.Errorf("должно было запуститься восстановление стресса — он ниже обычного пола") + } +} + +func TestSpendAntidepressantNeverGoesBelowZero(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Stress = 5 + s.Resources[StoryResKlondike] = 5 + s.SpendAntidepressant(now) + if s.Stress != 0 { + t.Errorf("стресс не должен был уйти ниже 0, получено %d", s.Stress) + } +} + +func TestSpendAntidepressantFailsWithoutResource(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Resources[StoryResKlondike] = 0 + if s.SpendAntidepressant(now) { + t.Errorf("трата без ресурса не должна была пройти") + } +} + +func TestApplyStressRecoveryReturnsToFloorGradually(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Stress = 40 + s.Resources[StoryResKlondike] = 1 + s.SpendAntidepressant(now) // стресс 20 (на полу — восстановление не нужно) + + s.Stress = 60 + s.Resources[StoryResKlondike] = 1 + s.SpendAntidepressant(now) // стресс 40, всё ещё выше пола — тоже без восстановления + if s.StressRecoveryActive { + t.Fatalf("восстановление не должно было запуститься — стресс всё ещё не ниже пола") + } + + s.Stress = 25 + s.Resources[StoryResKlondike] = 1 + s.SpendAntidepressant(now) // стресс 5, ниже пола — запускается восстановление + + s.ApplyStressRecovery(now.Add(30 * time.Minute)) + if s.Stress < 10 || s.Stress > 15 { + t.Errorf("через полчаса ожидался стресс примерно посередине между 5 и 20, получено %d", s.Stress) + } + if !s.StressRecoveryActive { + t.Errorf("восстановление ещё не должно было завершиться через полчаса") + } +} + +func TestApplyStressRecoveryCompletesAfterAnHour(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Stress = 25 + s.Resources[StoryResKlondike] = 1 + s.SpendAntidepressant(now) // стресс 5, ниже пола + + s.ApplyStressRecovery(now.Add(time.Hour)) + if s.Stress != StoryStressFloor { + t.Errorf("через час стресс должен был вернуться к полу %d, получено %d", StoryStressFloor, s.Stress) + } + if s.StressRecoveryActive { + t.Errorf("восстановление должно было завершиться (деактивироваться) через час") + } +} + +func TestApplyStressRecoveryDoesNothingWhenInactive(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Stress = 20 + s.ApplyStressRecovery(time.Now()) + if s.Stress != 20 { + t.Errorf("без активного восстановления стресс не должен был измениться, получено %d", s.Stress) + } +} + +func TestSpendAntidoteReducesPoisoningWithFloorZero(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Poisoning = 15 + s.Resources[StoryResMinesweeper] = 2 + if !s.SpendAntidote() { + t.Fatalf("трата антидота должна была пройти") + } + if s.Poisoning != 0 { + t.Errorf("ожидалось отравление 0 (15-20, ограничено полом), получено %d", s.Poisoning) + } +} + +func TestSpendAirFilterMarksUsedToday(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResGo] = 1 + if !s.SpendAirFilter() { + t.Fatalf("трата фильтра должна была пройти") + } + if !s.FilterUsedToday { + t.Errorf("фильтр должен был отметиться использованным сегодня") + } + if s.Resources[StoryResGo] != 0 { + t.Errorf("фильтр должен был списаться, получено %d", s.Resources[StoryResGo]) + } +} + +func TestSpendBatteryStartsAirPurification(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Resources[StoryResOneOhOne] = 1 + s.ToxinLevel = 80 + if !s.SpendBattery(now) { + t.Fatalf("трата батарейки должна была пройти") + } + if !s.AirPurificationActive { + t.Errorf("очистка воздуха должна была запуститься") + } + if s.AirPurificationFromLevel != 80 { + t.Errorf("ожидалось запомненное начальное значение токсина 80, получено %d", s.AirPurificationFromLevel) + } +} + +func TestApplyAirPurificationReducesToxinGradually(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Resources[StoryResOneOhOne] = 1 + s.ToxinLevel = 80 + s.SpendBattery(now) + + s.ApplyAirPurification(now.Add(30 * time.Minute)) // половина часа — примерно половина токсина + if s.ToxinLevel < 30 || s.ToxinLevel > 50 { + t.Errorf("через полчаса ожидался токсин примерно в районе 40, получено %d", s.ToxinLevel) + } + if !s.AirPurificationActive { + t.Errorf("очистка ещё не должна была завершиться через полчаса") + } +} + +func TestApplyAirPurificationCompletesAfterAnHour(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + s.Resources[StoryResOneOhOne] = 1 + s.ToxinLevel = 80 + s.SpendBattery(now) + + s.ApplyAirPurification(now.Add(time.Hour)) + if s.ToxinLevel != 0 { + t.Errorf("через час токсин должен был дойти до 0, получено %d", s.ToxinLevel) + } + if s.AirPurificationActive { + t.Errorf("очистка должна была завершиться (деактивироваться) через час") + } +} + +func TestApplyAirPurificationDoesNothingWhenInactive(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.ToxinLevel = 50 + s.ApplyAirPurification(time.Now()) + if s.ToxinLevel != 50 { + t.Errorf("без активной очистки токсин не должен был измениться, получено %d", s.ToxinLevel) + } +} + +func TestSpendResourceUnlocksIfDropsBelowTarget(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResGo] = StoryTargetGo + s.Locked[StoryResGo] = true + s.spendResource(StoryResGo, 1) + if s.Locked[StoryResGo] { + t.Errorf("ресурс должен был перестать считаться запертым, опустившись ниже цели") + } +} + +func TestSpendResourceKeepsLockedIfStillAtOrAboveTarget(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.Resources[StoryResGo] = StoryTargetGo + 5 + s.Locked[StoryResGo] = true + s.spendResource(StoryResGo, 1) + if !s.Locked[StoryResGo] { + t.Errorf("ресурс должен был остаться запертым — он всё ещё выше цели") + } +} + +func TestStoryNeurotoxinProbabilityMapping(t *testing.T) { + cases := []struct { + days int + want float64 + }{ + {0, 0}, + {1, 0.5}, + {2, 0.75}, + {3, 1.0}, + {10, 1.0}, + } + for _, c := range cases { + if got := storyNeurotoxinProbability(c.days); got != c.want { + t.Errorf("для %d дней без фильтра ожидалась вероятность %v, получено %v", c.days, c.want, got) + } + } +} + +func TestStoryDoorKnockProbabilityMapping(t *testing.T) { + cases := []struct { + days int + want float64 + }{ + {0, 0}, + {1, 0.1}, + {2, 0.5}, + {3, 1.0}, + {10, 1.0}, + } + for _, c := range cases { + if got := storyDoorKnockProbability(c.days); got != c.want { + t.Errorf("для %d дней без стука ожидалась вероятность %v, получено %v", c.days, c.want, got) + } + } +} + +func TestProcessDayEventsNoReleaseWhenFilterUsedToday(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.FilterUsedToday = true + s.DaysSinceFilterUsed = 5 // неважно, каким было раньше — фильтр использован сегодня + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if s.DaysSinceFilterUsed != 0 { + t.Errorf("счётчик дней без фильтра должен был сброситься в 0, получено %d", s.DaysSinceFilterUsed) + } + if s.PendingNeurotoxinAlert { + t.Errorf("выброс не должен был произойти — фильтр использован сегодня") + } + if s.FilterUsedToday { + t.Errorf("флаг 'использован сегодня' должен был сброситься к следующему дню") + } +} + +func TestProcessDayEventsGuaranteedReleaseAfterThreeDays(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.DaysSinceFilterUsed = 2 // станет 3 после инкремента — гарантированный выброс + before := s.ToxinLevel + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if !s.PendingNeurotoxinAlert { + t.Fatalf("на третьи сутки без фильтра выброс должен был произойти гарантированно") + } + if s.ToxinLevel <= before { + t.Errorf("содержание токсина должно было вырасти, получено %d (было %d)", s.ToxinLevel, before) + } + increase := s.ToxinLevel - before + if increase < 10 || increase > 70 { + t.Errorf("мощность выброса должна была быть в диапазоне 10-70, получено %d", increase) + } +} + +func TestProcessDayEventsNoReleaseOnFirstNeglectedDay(t *testing.T) { + // вероятность на первый день без фильтра — 50%, а не 0 и не 100%; + // проверяем хотя бы то, что при вероятности 0 (только что менялся) + // выброса точно не будет — это уже покрыто выше. Здесь же + // проверяем, что счётчик корректно увеличивается на 1 при + // отсутствии использования. + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.DaysSinceFilterUsed = 0 + s.FilterUsedToday = false + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if s.DaysSinceFilterUsed != 1 { + t.Errorf("счётчик дней без фильтра должен был увеличиться до 1, получено %d", s.DaysSinceFilterUsed) + } +} + +func TestProcessDayEventsGuaranteedKnockAfterThreeDays(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.DaysSinceDoorKnock = 2 // станет 3 — гарантированный стук + s.Stress = 30 + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if !s.PendingDoorKnockAlert { + t.Fatalf("на третьи сутки без стука он должен был произойти гарантированно") + } + if s.Stress != 50 { + t.Errorf("стресс должен был вырасти на 20 (было 30), получено %d", s.Stress) + } + if s.DaysSinceDoorKnock != 0 { + t.Errorf("счётчик дней без стука должен был сброситься, получено %d", s.DaysSinceDoorKnock) + } +} + +func TestProcessDayEventsStressCappedAtHundred(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.DaysSinceDoorKnock = 2 + s.Stress = 95 + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if s.Stress != 100 { + t.Errorf("стресс не должен был превысить 100, получено %d", s.Stress) + } +} + +func TestProcessDayEventsToxinCappedAtHundred(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + s.DaysSinceFilterUsed = 2 + s.ToxinLevel = 90 + rnd := rand.New(rand.NewSource(1)) + s.processDayEvents(rnd) + if s.ToxinLevel != 100 { + t.Errorf("токсин не должен был превысить 100, получено %d", s.ToxinLevel) + } +} + +func TestTickProcessesEachDaySeparatelyOverMultipleDays(t *testing.T) { + now := time.Now() + s := NewStoryModeState(StoryDifficultyEasy, now) + // 5 суток без фильтра — на третий и далее день выброс гарантирован, + // так что хотя бы один точно должен был случиться + days := s.Tick(now.Add(5 * 24 * time.Hour)) + if days != 5 { + t.Fatalf("ожидалось 5 учтённых суток, получено %d", days) + } + if !s.PendingNeurotoxinAlert { + t.Errorf("за 5 суток без фильтра выброс должен был произойти хотя бы раз (гарантированно с 3-х суток)") + } + if !s.PendingDoorKnockAlert { + t.Errorf("за 5 суток стук в дверь тоже должен был произойти хотя бы раз (гарантированно с 3-х суток)") + } +} diff --git a/story_save.go b/story_save.go new file mode 100644 index 0000000..99973c8 --- /dev/null +++ b/story_save.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// ErrStoryModeNoSave возвращается LoadStoryModeState, если файл +// сохранения не найден (сторимод ещё не был начат). +var ErrStoryModeNoSave = errors.New("файл сохранения сторимода не найден") + +// storyModeSavePath возвращает путь к файлу сохранения сторимода в +// пользовательской конфигурационной директории. +func storyModeSavePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "go-games-collection", "story_save.json"), nil +} + +// SaveStoryModeState сохраняет текущее состояние сторимода на диск, +// создавая директорию сохранения при необходимости. +func SaveStoryModeState(s *StoryModeState) error { + path, err := storyModeSavePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// LoadStoryModeState загружает сохранённый сторимод с диска. +// Возвращает ErrStoryModeNoSave, если файла сохранения нет — +// вызывающий код должен в этом случае предложить выбрать сложность +// и начать заново. +func LoadStoryModeState() (*StoryModeState, error) { + path, err := storyModeSavePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrStoryModeNoSave + } + return nil, err + } + var s StoryModeState + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} + +// DeleteStoryModeSave удаляет файл сохранения сторимода, если он +// есть (используется для явного сброса прогресса). Отсутствие файла +// не считается ошибкой. +func DeleteStoryModeSave() error { + path, err := storyModeSavePath() + if err != nil { + return err + } + err = os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/story_save_test.go b/story_save_test.go new file mode 100644 index 0000000..a2c042e --- /dev/null +++ b/story_save_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "testing" + "time" +) + +func TestSaveLoadStoryModeStateRoundTrip(t *testing.T) { + withTempConfigDir(t) + s := NewStoryModeState(StoryDifficultyHard, time.Now()) + s.RecordWin(StoryResChess) + s.RecordWin(StoryResChess) + s.Locked[StoryResGo] = true + s.Resources[StoryResGo] = StoryTargetGo + + if err := SaveStoryModeState(s); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + loaded, err := LoadStoryModeState() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + if loaded.Difficulty != StoryDifficultyHard { + t.Errorf("сложность не сохранилась корректно, получено %v", loaded.Difficulty) + } + if loaded.Resources[StoryResChess] != storyStartingAmount+2 { + t.Errorf("ресурс шахмат не сохранился корректно, получено %d", loaded.Resources[StoryResChess]) + } + if !loaded.Locked[StoryResGo] { + t.Errorf("запертый ресурс Го должен был остаться запертым после загрузки") + } +} + +func TestLoadStoryModeStateNoSaveReturnsErr(t *testing.T) { + withTempConfigDir(t) + _, err := LoadStoryModeState() + if err != ErrStoryModeNoSave { + t.Errorf("ожидалась ошибка ErrStoryModeNoSave при отсутствии сохранения, получено: %v", err) + } +} + +func TestDeleteStoryModeSaveRemovesFile(t *testing.T) { + withTempConfigDir(t) + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if err := SaveStoryModeState(s); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + if err := DeleteStoryModeSave(); err != nil { + t.Fatalf("неожиданная ошибка удаления: %v", err) + } + if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { + t.Errorf("после удаления сохранения ожидалась ErrStoryModeNoSave, получено: %v", err) + } +} + +func TestDeleteStoryModeSaveWithoutExistingFileIsNotError(t *testing.T) { + withTempConfigDir(t) + if err := DeleteStoryModeSave(); err != nil { + t.Errorf("удаление отсутствующего сохранения не должно быть ошибкой, получено: %v", err) + } +} diff --git a/story_tui.go b/story_tui.go new file mode 100644 index 0000000..8436a2d --- /dev/null +++ b/story_tui.go @@ -0,0 +1,1096 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// --- Соответствие сложности сторимода уровню сложности конкретной +// игры. Двухступенчатые игры (Шашки, Шахматы) получают "простой" на +// лёгком и среднем сторимода и "сильный" только на сложном; +// трёхступенчатые — соответствие один-в-один. + +func storyDifficultyToTicTacToe(d StoryDifficulty) TicTacToeDifficulty { + switch d { + case StoryDifficultyEasy: + return TicTacToeDifficultyEasy + case StoryDifficultyMedium: + return TicTacToeDifficultyMedium + default: + return TicTacToeDifficultyHard + } +} + +func storyDifficultyToCheckersDepth(d StoryDifficulty) int { + if d == StoryDifficultyHard { + return CheckersDifficultyHard + } + return CheckersDifficultyEasy +} + +func storyDifficultyToChessDepth(d StoryDifficulty) int { + if d == StoryDifficultyHard { + return ChessDifficultyHard + } + return ChessDifficultyEasy +} + +func storyDifficultyToGo(d StoryDifficulty) GoDifficulty { + switch d { + case StoryDifficultyEasy: + return GoDifficultyEasy + case StoryDifficultyMedium: + return GoDifficultyMedium + default: + return GoDifficultyHard + } +} + +func storyDifficultyToUr(d StoryDifficulty) UrDifficulty { + switch d { + case StoryDifficultyEasy: + return UrDifficultyEasy + case StoryDifficultyMedium: + return UrDifficultyMedium + default: + return UrDifficultyHard + } +} + +func storyDifficultyToSenet(d StoryDifficulty) SenetDifficulty { + switch d { + case StoryDifficultyEasy: + return SenetDifficultyEasy + case StoryDifficultyMedium: + return SenetDifficultyMedium + default: + return SenetDifficultyHard + } +} + +func storyDifficultyToNardy(d StoryDifficulty) NardyDifficulty { + switch d { + case StoryDifficultyEasy: + return NardyDifficultyEasy + case StoryDifficultyMedium: + return NardyDifficultyMedium + default: + return NardyDifficultyHard + } +} + +// --- Определение исхода партии для каждой подключённой игры: партия +// завершилась и (если да) выиграл ли человек. Возвращает (false, +// false), если партия ещё идёт или тип модели не тот, что ожидался. + +func storyOutcomeTicTacToe(m tea.Model) (finished, won bool) { + tm, ok := m.(TicTacToeModel) + if !ok || tm.game.Result == nil { + return false, false + } + winner := tm.game.Result.Winner + // в сторимоде ничья приравнена к победе — партия не проиграна, + // только если противник выиграл явно + return true, winner == tm.game.HumanMark || winner == TicTacToeEmpty +} + +func storyOutcomeCheckers(m tea.Model) (finished, won bool) { + tm, ok := m.(CheckersModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.human +} + +func storyOutcomeChess(m tea.Model) (finished, won bool) { + tm, ok := m.(ChessModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.human +} + +func storyOutcomeNardy(m tea.Model) (finished, won bool) { + tm, ok := m.(NardyModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.humanColor +} + +func storyOutcomeGo(m tea.Model) (finished, won bool) { + tm, ok := m.(GoModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.human +} + +func storyOutcomeUr(m tea.Model) (finished, won bool) { + tm, ok := m.(UrModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.humanColor +} + +func storyOutcomeSenet(m tea.Model) (finished, won bool) { + tm, ok := m.(SenetModel) + if !ok || tm.game.Result == nil { + return false, false + } + return true, tm.game.Result.Winner == tm.humanColor +} + +// storyOutcomeBlackjack — правило для карточных игр на деньги: +// партия засчитывается только если сыграно не меньше трёх раундов и +// баланс человека в итоге выше стартового капитала. Если раундов +// меньше трёх (сдался раньше) или баланс не вырос — засчитывается +// как незавершённая попытка, ресурс не начисляется. +func storyOutcomeBlackjack(m tea.Model) (finished, won bool) { + bm, ok := m.(BlackjackModel) + if !ok || bm.game.Phase != BJPhaseRoundOver { + return false, false + } + human := bm.game.Players[bm.humanIdx] + won = bm.game.RoundsPlayed >= 3 && human.Balance > bm.game.StartingCapital + return true, won +} + +// storyOutcomeTonk — тот же принцип, что и в Блэкджеке: минимум три +// раунда сыграно, и на момент выхода баланс человека не ниже, чем у +// любого другого игрока за столом (ничья за первое место тоже +// засчитывается). +func storyOutcomeTonk(m tea.Model) (finished, won bool) { + tm, ok := m.(Model) + if !ok || tm.game.Phase != PhaseGameOver { + return false, false + } + if tm.game.RoundsPlayed < 3 { + return true, false + } + human := tm.game.Players[tm.humanIdx] + maxBalance := human.Balance + for _, p := range tm.game.Players { + if p.Balance > maxBalance { + maxBalance = p.Balance + } + } + return true, human.Balance == maxBalance +} + +// storyOutcomeDurak — нужно 3 подряд партии в этой сессии без +// поражения (не быть дураком). storyNonFoolStreak хранит серию ДО +// текущей партии; здесь отдельно учитывается исход именно этой, +// последней партии — иначе выход сразу после решающей победы (не +// через "n") потерял бы её из серии. +func storyOutcomeDurak(m tea.Model) (finished, won bool) { + dm, ok := m.(DurakModel) + if !ok || dm.game.Result == nil { + return false, false + } + wasFool := !dm.game.Result.NoLoser && !dm.game.Result.Stalemate && dm.game.Result.Loser == dm.humanIdx + effectiveStreak := dm.storyNonFoolStreak + 1 + if wasFool { + effectiveStreak = 0 + } + return true, effectiveStreak >= 3 +} + +// storyOutcomeMinesweeper — нужно 3 подряд партии в этой сессии без +// взрыва. Как и в Дураке, последняя (текущая) партия учитывается +// отдельно от уже накопленной серии — иначе выход сразу после +// решающей победы потерял бы её из счёта. +func storyOutcomeMinesweeper(m tea.Model) (finished, won bool) { + mm, ok := m.(MinesweeperModel) + if !ok || mm.game.Result == nil { + return false, false + } + effectiveStreak := mm.storyWinStreak + if mm.game.Result.Won { + effectiveStreak++ + } else { + effectiveStreak = 0 + } + return true, effectiveStreak >= 3 +} + +// storyOutcomeOneOhOne — простая победа, но во всём МАТЧЕ (в 101 +// счёт идёт через несколько раундов, отдельный раунд ничего не +// решает), а не в одном отдельном раунде. +func storyOutcomeOneOhOne(m tea.Model) (finished, won bool) { + om, ok := m.(OneOhOneModel) + if !ok || om.game.Phase != OneOhOnePhaseRoundOver || om.game.Result == nil { + return false, false + } + if !om.game.Result.MatchOver { + return false, false // матч ещё продолжается — рано подводить итог + } + return true, !om.game.Result.NoWinner && om.game.Result.MatchWinner == om.humanIdx +} + +// storyOutcomeThousand — тот же принцип, что и в Блэкджеке/Тонке: +// минимум 3 раздачи сыграно, и на момент выхода счёт человека не +// ниже, чем у любого другого игрока (ничья за первое место тоже +// засчитывается). +func storyOutcomeThousand(m tea.Model) (finished, won bool) { + tm, ok := m.(ThousandModel) + if !ok || tm.game.Phase != ThousandPhaseHandOver { + return false, false + } + if tm.game.HandNumber < 3 { + return true, false + } + human := tm.game.Players[tm.humanIdx] + maxScore := human.Score + for _, p := range tm.game.Players { + if p.Score > maxScore { + maxScore = p.Score + } + } + return true, human.Score == maxScore +} + +// storyOutcomeKlondike сообщает только "выиграна ли партия" — сам +// подсчёт "3 победы за день" происходит отдельно, на уровне +// диспетчера в menu.go (см. KlondikeWinsToday), а не здесь. +func storyOutcomeKlondike(m tea.Model) (finished, won bool) { + km, ok := m.(KlondikeModel) + if !ok || !km.game.Won { + return false, false + } + return true, true +} + +// storyOutcomePoker — выигрыш ВСЕГО турнира (последний оставшийся +// игрок с фишками), а не отдельной раздачи. Сложность сторимода на +// покер сейчас не влияет — уровень каждого бота и так назначается +// случайно независимо от общей сложности (см. NewPokerModel). +func storyOutcomePoker(m tea.Model) (finished, won bool) { + pm, ok := m.(PokerModel) + if !ok || pm.game.Phase != PokerPhaseTournamentOver { + return false, false + } + return true, !pm.game.Players[pm.humanSeat].Eliminated +} + +// storyGameDef — описание одной игры, доступной в сторимоде: как её +// запустить (уже с фиксированными настройками и сложностью +// сторимода вместо обычного экрана настройки) и как определить, +// выиграл ли человек, когда партия завершилась. +type storyGameDef struct { + NameKey string + Launch func(difficulty StoryDifficulty) tea.Model + Outcome func(m tea.Model) (finished, won bool) +} + +// storyGameDefs — только подключённые на сегодня игры (чистое +// условие победы: Result != nil, сравнение Winner с самим собой). +// Игры со составной семантикой исхода партии (Дурак, Блэкджек, 101, +// 1000, Косынка, Сапёр, Покер) требуют отдельного решения по +// каждой и будут подключены следующим шагом — намеренно не +// угадываю их условие победы второпях. +var storyGameDefs = map[StoryResourceID]storyGameDef{ + StoryResTicTacToe: { + NameKey: "game.tictactoe.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewTicTacToeModel(storyDifficultyToTicTacToe(d)) + }, + Outcome: storyOutcomeTicTacToe, + }, + StoryResCheckers: { + NameKey: "game.checkers.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewCheckersModel(storyDifficultyToCheckersDepth(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeCheckers, + }, + StoryResChess: { + NameKey: "game.chess.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewChessModel(storyDifficultyToChessDepth(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeChess, + }, + StoryResNardy: { + NameKey: "game.nardy.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewNardyModel(NardyShort, storyDifficultyToNardy(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeNardy, + }, + StoryResGo: { + NameKey: "game.go.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewGoModel(9, storyDifficultyToGo(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeGo, + }, + StoryResUr: { + NameKey: "game.ur.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewUrModel(storyDifficultyToUr(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeUr, + }, + StoryResSenet: { + NameKey: "game.senet.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewSenetModel(storyDifficultyToSenet(d), ColorChoiceRandom) + }, + Outcome: storyOutcomeSenet, + }, + StoryResBlackjack: { + NameKey: "game.blackjack.name", + Launch: func(d StoryDifficulty) tea.Model { + // сложность сторимода на Блэкджек не влияет — у дилера + // нет уровня "бота", он всегда играет по фиксированным + // правилам казино; человек играет один на один с дилером + return NewBlackjackModel(1, 500, 25) + }, + Outcome: storyOutcomeBlackjack, + }, + StoryResTonk: { + NameKey: "game.tonk.name", + Launch: func(d StoryDifficulty) tea.Model { + // у ботов Тонка нет уровня сложности — фиксированные стратегии + return NewModel() + }, + Outcome: storyOutcomeTonk, + }, + StoryResDurak: { + NameKey: "game.durak.name", + Launch: func(d StoryDifficulty) tea.Model { + // у бота Дурака тоже нет уровня сложности + return NewDurakModel(4) + }, + Outcome: storyOutcomeDurak, + }, + StoryResMinesweeper: { + NameKey: "game.minesweeper.name", + Launch: func(d StoryDifficulty) tea.Model { + // Сапёр без противника — сложность сторимода не при делах; + // берём "новичок" (9x9, 10 мин) — задача про серию из 3 + // побед и так достаточно требовательна + return NewMinesweeperModel(9, 9, 10) + }, + Outcome: storyOutcomeMinesweeper, + }, + StoryResOneOhOne: { + NameKey: "game.oneohone.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewOneOhOneModel(3) + }, + Outcome: storyOutcomeOneOhOne, + }, + StoryResThousand: { + NameKey: "game.thousand.name", + Launch: func(d StoryDifficulty) tea.Model { + // 1000 поддерживает только троих игроков (правила игры) + return NewThousandModel() + }, + Outcome: storyOutcomeThousand, + }, + StoryResKlondike: { + NameKey: "game.klondike.name", + Launch: func(d StoryDifficulty) tea.Model { + // Косынка — головоломка без противника, сложность не при делах + return NewKlondikeModel() + }, + Outcome: storyOutcomeKlondike, + }, + StoryResPoker: { + NameKey: "game.poker.name", + Launch: func(d StoryDifficulty) tea.Model { + return NewPokerModel(4) + }, + Outcome: storyOutcomePoker, + }, +} + +// storyHubOrder — порядок показа ресурсов в хабе (стабильный, +// отдельный от порядка iota, чтобы можно было перегруппировать +// показ, не трогая сами значения StoryResourceID). +var storyHubOrder = []StoryResourceID{ + StoryResTonk, StoryResDurak, StoryResCheckers, StoryResNardy, + StoryResBlackjack, StoryResChess, StoryResMinesweeper, StoryResGo, + StoryResUr, StoryResSenet, StoryResOneOhOne, StoryResThousand, + StoryResKlondike, StoryResTicTacToe, StoryResPoker, +} + +func (m AppModel) updateStoryDifficulty(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateModeSelect + return m, nil + case "up", "k": + if m.storyDifficultyCursor > 0 { + m.storyDifficultyCursor-- + } + case "down", "j": + if m.storyDifficultyCursor < 2 { + m.storyDifficultyCursor++ + } + case "enter": + m.story = NewStoryModeState(StoryDifficulty(m.storyDifficultyCursor), time.Now()) + _ = SaveStoryModeState(m.story) + m.state = stateStoryHub + } + return m, nil +} + +func (m AppModel) viewStoryDifficulty() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.difficulty.title"))) + fmt.Fprintln(&b) + + options := []string{T("story.difficulty.easy"), T("story.difficulty.medium"), T("story.difficulty.hard")} + for i, opt := range options { + cursor := " " + label := menuItemStyle.Render(opt) + if i == m.storyDifficultyCursor { + cursor = menuCursorStyle.Render("> ") + label = menuCursorStyle.Render(opt) + } + fmt.Fprintf(&b, "%s%s\n", cursor, label) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint"))) + return b.String() +} + +// storyEscapeUnlocked проверяет условие скрытой концовки — все три +// разом: 50 кусков мыла (Крестики-нолики), 50 верёвок (Сенет) и +// стресс 60% и выше. +func (m AppModel) storyEscapeUnlocked() bool { + enoughSoap := m.story.Resources[StoryResTicTacToe] >= 50 + enoughRope := m.story.Resources[StoryResSenet] >= 50 + highStress := m.story.Stress >= 60 + return enoughSoap && enoughRope && highStress +} + +// storyVisibleGames возвращает список игр, доступных прямо сейчас — +// на этапе знакомства это укороченный список (только крестики-нолики, +// затем плюс шашки), после его завершения — обычный полный список. +func (m AppModel) storyVisibleGames() []StoryResourceID { + switch m.story.Onboarding { + case StoryOnboardingTicTacToeOnly: + return []StoryResourceID{StoryResTicTacToe} + case StoryOnboardingWithCheckers: + return []StoryResourceID{StoryResTicTacToe, StoryResCheckers} + default: + return storyHubOrder + } +} + +func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if m.storyConfirmingReset { + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "y", "Y": + _ = DeleteStoryModeSave() + m.story = nil + m.storyConfirmingReset = false + m.storyPostGameFlavor = "" + m.storyHubCursor = 0 + m.state = stateStoryDifficulty + case "n", "N", "esc": + m.storyConfirmingReset = false + } + return m, nil + } + visible := m.storyVisibleGames() + onboarding := m.story.Onboarding != StoryOnboardingDone + maxCursor := len(visible) - 1 + if !onboarding && m.storyEscapeUnlocked() { + maxCursor++ // дополнительный пункт "Сбежать из бункера" в конце списка + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "N": + m.storyConfirmingReset = true + return m, nil + case "esc", "q": + m.state = stateModeSelect + return m, nil + case "up", "k": + if m.storyHubCursor > 0 { + m.storyHubCursor-- + } + case "down", "j": + if m.storyHubCursor < maxCursor { + m.storyHubCursor++ + } + case "c": + if onboarding { + return m, nil + } + m.state = stateStoryConditions + return m, nil + case "u": + if onboarding { + return m, nil + } + m.state = stateStoryUseItem + return m, nil + case "enter": + if !onboarding && m.storyEscapeUnlocked() && m.storyHubCursor == len(visible) { + m.state = stateStoryEscapeAttempt + return m, nil + } + if m.storyHubCursor >= len(visible) { + return m, nil + } + id := visible[m.storyHubCursor] + def, playable := storyGameDefs[id] + if !playable { + return m, nil + } + resource := id + m.storyActiveResource = &resource + m.activeGame = def.Launch(m.story.Difficulty) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) viewStoryHub() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.hub.title"))) + fmt.Fprintln(&b) + + if m.storyConfirmingReset { + fmt.Fprintln(&b, errorStyle.Render(T("story.hub.reset_confirm"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.reset_confirm_hint"))) + return b.String() + } + + onboarding := m.story.Onboarding != StoryOnboardingDone + visible := m.storyVisibleGames() + + if !onboarding { + fmt.Fprintln(&b, dimStyle.Render(T("story.hub.difficulty_label")+" "+T(storyDifficultyLabelKey(m.story.Difficulty)))) + fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel)) + if m.storyPostGameFlavor != "" { + fmt.Fprintln(&b, infoStyle.Render(T(m.storyPostGameFlavor))) + } + fmt.Fprintln(&b) + } + + for i, id := range visible { + resDef := storyResourceDefs[id] + cursor := " " + var line string + if onboarding { + // на этапе знакомства — только название игры, без чисел + // ресурсов и прочего антуража обычного лагеря; и именно + // название ИГРЫ, а не ресурса — до объяснения от ИИ + // "кусок мыла"/"банка тушёнки" ничего не значат для игрока + line = T(storyGameDefs[id].NameKey) + } else { + _, playable := storyGameDefs[id] + status := fmt.Sprintf("%d/%d", m.story.Resources[id], resDef.Target) + if m.story.Locked[id] { + status = T("story.hub.locked") + } + line = fmt.Sprintf("%-24s %s", T(resDef.NameKey), status) + if !playable { + line += " " + dimStyle.Render(T("story.hub.notyet")) + } + if !playable && i != m.storyHubCursor { + line = dimStyle.Render(line) + } + } + if i == m.storyHubCursor { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + if !onboarding && m.storyEscapeUnlocked() { + cursor := " " + line := errorStyle.Render(T("story.hub.escape_option")) + if m.storyHubCursor == len(visible) { + cursor = menuCursorStyle.Render("> ") + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + if !onboarding && m.story.Complete() { + fmt.Fprintln(&b, winStyle.Render(T("story.hub.complete"))) + fmt.Fprintln(&b) + } + if onboarding { + fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint.onboarding"))) + } else { + fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint"))) + } + return b.String() +} + +// storyGreetingKind — какое из трёх приветствий дня показать при +// входе в сторимод именно через меню режимов (выбор "Режим +// истории") — это про начало новой сессии, а не про возврат из +// только что сыгранной партии внутри уже идущей сессии. +type storyGreetingKind int + +const ( + storyGreetingNone storyGreetingKind = iota + storyGreetingWokeUp // прошёл ровно один день + storyGreetingPlayingAgain // 0 дней — снова зашёл в тот же день + storyGreetingWorried // 2 и более дня +) + +func storyGreetingForDays(days int) storyGreetingKind { + switch { + case days == 0: + return storyGreetingPlayingAgain + case days == 1: + return storyGreetingWokeUp + default: + return storyGreetingWorried + } +} + +func (k storyGreetingKind) textKey() string { + switch k { + case storyGreetingWokeUp: + return "story.morning.text.wokeup" + case storyGreetingPlayingAgain: + return "story.morning.text.again" + case storyGreetingWorried: + return "story.morning.text.worried" + default: + return "story.morning.text.wokeup" + } +} + +// storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ +// убежища, одна из которых случайно показывается в лагере после +// возврата из только что сыгранной партии — заметки о +// физиологической реакции игрока по показаниям датчиков. +var storyPostGameFlavorKeys = []string{ + "story.postgame.flavor1", + "story.postgame.flavor2", + "story.postgame.flavor3", + "story.postgame.flavor4", + "story.postgame.flavor5", + "story.postgame.flavor6", + "story.postgame.flavor7", + "story.postgame.flavor8", + "story.postgame.flavor9", + "story.postgame.flavor10", + "story.postgame.flavor11", + "story.postgame.flavor12", +} + +func randomStoryPostGameFlavor() string { + return storyPostGameFlavorKeys[rand.Intn(len(storyPostGameFlavorKeys))] +} + +// enterStoryHubFromModeSelect применяет накопившийся суточный расход +// (и очистку воздуха/восстановление стресса) и показывает одно из +// трёх приветствий дня, в зависимости от того, сколько суток прошло +// с последнего входа именно через меню режимов. +func (m AppModel) enterStoryHubFromModeSelect() AppModel { + days := m.story.Tick(time.Now()) + m.story.ApplyAirPurification(time.Now()) + m.story.ApplyStressRecovery(time.Now()) + _ = SaveStoryModeState(m.story) + if m.story.Onboarding != StoryOnboardingDone { + // на этапе знакомства ИИ убежища ещё не представилась игроку + // (это происходит только в момент завершения знакомства, см. + // AdvanceOnboarding) — рано показывать её приветствия дня и + // суточные события; они тихо копятся (Tick выше их всё равно + // обсчитал) и всплывут при первом же входе после завершения + m.state = stateStoryHub + return m + } + m.storyGreeting = storyGreetingForDays(days) + m.state = stateStoryMorning + return m +} + +// enterStoryHub используется при возврате из только что сыгранной +// партии: применяет тот же накопившийся расход и очистку/восстановление +// молча, без приветствия дня (это продолжение уже идущей сессии, а +// не новый вход) — но показывает случайную короткую фразу-наблюдение +// поверх обычного хаба (см. storyPostGameFlavorKeys). +func (m AppModel) enterStoryHub() AppModel { + m.story.Tick(time.Now()) + m.story.ApplyAirPurification(time.Now()) + m.story.ApplyStressRecovery(time.Now()) + _ = SaveStoryModeState(m.story) + m.state = stateStoryHub + return m +} + +// advanceStoryEventQueue решает, куда вести дальше после закрытия +// текущего экрана-уведомления (приветствие дня, выброс нейротоксина, +// стук в дверь): если есть ещё не показанное событие — на него (по +// порядку выброс, затем стук), иначе — в обычный хаб. +func (m AppModel) advanceStoryEventQueue() appState { + if m.story.PendingNeurotoxinAlert { + return stateStoryNeurotoxinAlert + } + if m.story.PendingDoorKnockAlert { + return stateStoryDoorKnockAlert + } + return stateStoryHub +} + +func (m AppModel) updateStoryMorning(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.state = m.advanceStoryEventQueue() + return m, nil +} + +func (m AppModel) viewStoryMorning() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.morning.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T(m.storyGreeting.textKey()), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) + return b.String() +} + +func (m AppModel) updateStoryNeurotoxinAlert(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.story.PendingNeurotoxinAlert = false + _ = SaveStoryModeState(m.story) + m.state = m.advanceStoryEventQueue() + return m, nil +} + +func (m AppModel) viewStoryNeurotoxinAlert() string { + var b strings.Builder + fmt.Fprintln(&b, errorStyle.Render(T("story.event.neurotoxin.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(Tf("story.event.neurotoxin.text", m.story.ToxinLevel), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) + return b.String() +} + +func (m AppModel) updateStoryDoorKnockAlert(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.story.PendingDoorKnockAlert = false + _ = SaveStoryModeState(m.story) + m.state = m.advanceStoryEventQueue() + return m, nil +} + +func (m AppModel) viewStoryDoorKnockAlert() string { + var b strings.Builder + fmt.Fprintln(&b, errorStyle.Render(T("story.event.doorknock.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T("story.event.doorknock.text"), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) + return b.String() +} + +// storyWelcomeTickMsg сигнализирует, что пора показать ещё одно +// слово текста приветствия (эффект постепенного появления). +type storyWelcomeTickMsg struct{} + +// storyWelcomeWordInterval — 4 слова в секунду. +const storyWelcomeWordInterval = 250 * time.Millisecond + +func storyWelcomeTick() tea.Cmd { + return tea.Tick(storyWelcomeWordInterval, func(time.Time) tea.Msg { return storyWelcomeTickMsg{} }) +} + +// storyTotalWords считает слова во всём тексте, не различая абзацы +// (разделитель абзацев "\n\n" сам по себе словом не считается). +func storyTotalWords(text string) int { + return len(strings.Fields(text)) +} + +// storyRevealPartialText показывает первые n слов text, сохраняя +// структуру абзацев (текст может состоять из нескольких абзацев, +// разделённых "\n\n") — используется для эффекта постепенного +// появления текста. Абзацы показываются по порядку целиком, кроме, +// возможно, самого последнего показанного — он может быть показан +// частично. +func storyRevealPartialText(text string, n int) string { + if n <= 0 { + return "" + } + paragraphs := strings.Split(text, "\n\n") + var shown []string + remaining := n + for _, p := range paragraphs { + if remaining <= 0 { + break + } + words := strings.Fields(p) + if remaining >= len(words) { + shown = append(shown, p) + remaining -= len(words) + } else { + shown = append(shown, strings.Join(words[:remaining], " ")) + remaining = 0 + } + } + return strings.Join(shown, "\n\n") +} + +func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) { + total := storyTotalWords(T("story.welcome.text")) + switch msg := msg.(type) { + case storyWelcomeTickMsg: + if m.storyWelcomeWordsShown >= total { + return m, nil + } + m.storyWelcomeWordsShown++ + if m.storyWelcomeWordsShown < total { + return m, storyWelcomeTick() + } + return m, nil + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + if m.storyWelcomeWordsShown < total { + // текст ещё печатается — любая клавиша сразу показывает + // его целиком, а не продолжает в лагерь + m.storyWelcomeWordsShown = total + return m, nil + } + return m.enterStoryHub(), nil + } + return m, nil +} + +func (m AppModel) viewStoryWelcome() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.welcome.title"))) + fmt.Fprintln(&b) + partial := storyRevealPartialText(T("story.welcome.text"), m.storyWelcomeWordsShown) + fmt.Fprintln(&b, wrapText(partial, m.termWidth)) + fmt.Fprintln(&b) + if m.storyWelcomeWordsShown < storyTotalWords(T("story.welcome.text")) { + fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint.typing"))) + } else { + fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint"))) + } + return b.String() +} + +func (m AppModel) updateStoryConditions(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.state = stateStoryHub + return m, nil +} + +// storyConditionKeys — ключ перевода условия начисления ресурса, по +// каждой игре в том же порядке, что и storyHubOrder. +var storyConditionKeys = map[StoryResourceID]string{ + StoryResTonk: "story.condition.tonk", + StoryResDurak: "story.condition.durak", + StoryResCheckers: "story.condition.win", + StoryResNardy: "story.condition.win", + StoryResBlackjack: "story.condition.blackjack", + StoryResChess: "story.condition.win", + StoryResMinesweeper: "story.condition.minesweeper", + StoryResGo: "story.condition.win", + StoryResUr: "story.condition.win", + StoryResSenet: "story.condition.win", + StoryResOneOhOne: "story.condition.oneohone", + StoryResThousand: "story.condition.thousand", + StoryResKlondike: "story.condition.klondike", + StoryResTicTacToe: "story.condition.tictactoe", + StoryResPoker: "story.condition.poker", +} + +func (m AppModel) viewStoryConditions() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.conditions.title"))) + fmt.Fprintln(&b) + for _, id := range storyHubOrder { + resDef := storyResourceDefs[id] + fmt.Fprintf(&b, "%s\n %s\n", T(resDef.NameKey), dimStyle.Render(T(storyConditionKeys[id]))) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.conditions.hint"))) + return b.String() +} + +// storyUseItemDef — одно расходуемое действие на экране +// "использовать": какой ресурс тратится, что при этом происходит. +type storyUseItemDef struct { + ResourceID StoryResourceID + EffectKey string + Spend func(s *StoryModeState) bool +} + +var storyUseItemDefs = []storyUseItemDef{ + {ResourceID: StoryResKlondike, EffectKey: "story.useitem.antidepressant", Spend: func(s *StoryModeState) bool { return s.SpendAntidepressant(time.Now()) }}, + {ResourceID: StoryResMinesweeper, EffectKey: "story.useitem.antidote", Spend: func(s *StoryModeState) bool { return s.SpendAntidote() }}, + {ResourceID: StoryResGo, EffectKey: "story.useitem.airfilter", Spend: func(s *StoryModeState) bool { return s.SpendAirFilter() }}, + {ResourceID: StoryResOneOhOne, EffectKey: "story.useitem.battery", Spend: func(s *StoryModeState) bool { return s.SpendBattery(time.Now()) }}, +} + +func (m AppModel) updateStoryUseItem(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "esc", "q": + m.state = stateStoryHub + return m, nil + case "1", "2", "3", "4": + idx := int(keyMsg.String()[0] - '1') + if idx < 0 || idx >= len(storyUseItemDefs) { + return m, nil + } + if storyUseItemDefs[idx].Spend(m.story) { + _ = SaveStoryModeState(m.story) + } + } + return m, nil +} + +func (m AppModel) viewStoryUseItem() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.useitem.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel)) + fmt.Fprintln(&b) + for i, def := range storyUseItemDefs { + resDef := storyResourceDefs[def.ResourceID] + count := m.story.Resources[def.ResourceID] + fmt.Fprintf(&b, "[%d] %s: %d — %s\n", i+1, T(resDef.NameKey), count, T(def.EffectKey)) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.useitem.hint"))) + return b.String() +} + +func (m AppModel) updateStoryEscapeAttempt(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "1": + // таблетка успокоительного — стресс сразу и без остатка + // падает до нуля (в отличие от антидепрессанта, здесь нет + // последующего часового восстановления к полу) + m.story.Stress = 0 + m.story.StressRecoveryActive = false + _ = SaveStoryModeState(m.story) + m.state = stateStoryHub + case "2": + m.state = stateStoryEscapeEnding + case "esc", "q": + m.state = stateStoryHub + } + return m, nil +} + +func (m AppModel) viewStoryEscapeAttempt() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escape.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T("story.escape.text"), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("story.escape.stay")) + fmt.Fprintln(&b, errorStyle.Render(T("story.escape.leave"))) + return b.String() +} + +func (m AppModel) updateStoryEscapeEnding(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + m.state = stateModeSelect + return m, nil +} + +func (m AppModel) viewStoryEscapeEnding() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T("story.escapeending.text"), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.escapeending.hint"))) + return b.String() +} + +func storyDifficultyLabelKey(d StoryDifficulty) string { + switch d { + case StoryDifficultyEasy: + return "story.difficulty.easy" + case StoryDifficultyMedium: + return "story.difficulty.medium" + default: + return "story.difficulty.hard" + } +} \ No newline at end of file diff --git a/tonk b/tonk deleted file mode 100755 index 8fa729f..0000000 Binary files a/tonk and /dev/null differ diff --git a/translations.go b/translations.go index 18ef6e4..de871ff 100644 --- a/translations.go +++ b/translations.go @@ -8,7 +8,103 @@ var translations = map[string]translationEntry{ "lang.title": {"=== ВЫБОР ЯЗЫКА ===", "=== CHOOSE LANGUAGE ==="}, "lang.ru": {"Русский", "Русский (Russian)"}, "lang.en": {"English", "English"}, - "lang.hint": {"[↑↓/kj] выбор [enter] подтвердить", "[↑↓/kj] select [enter] confirm"}, + "lang.hint": {"[↑↓/kj] выбор [enter] подтвердить [Q] выход из игры", "[↑↓/kj] select [enter] confirm [Q] quit game"}, + + "modeselect.title": {"=== РЕЖИМ ===", "=== MODE ==="}, + "modeselect.games": {"Просто игры", "Just games"}, + "modeselect.story": {"Режим истории", "Story mode"}, + "modeselect.hint": {"[↑↓/kj] выбор [enter] подтвердить [esc/q] назад [Q] выход из игры", "[↑↓/kj] select [enter] confirm [esc/q] back [Q] quit game"}, + + "story.difficulty.title": {"=== СЛОЖНОСТЬ ИСТОРИИ ===", "=== STORY DIFFICULTY ==="}, + "story.difficulty.easy": {"Лёгкая", "Easy"}, + "story.difficulty.medium": {"Средняя", "Medium"}, + "story.difficulty.hard": {"Сложная", "Hard"}, + + "story.hub.title": {"=== ЛАГЕРЬ ===", "=== CAMP ==="}, + "story.hub.difficulty_label": {"Сложность:", "Difficulty:"}, + "story.hub.locked": {"в сейфе", "in the safe"}, + "story.hub.notyet": {"(пока недоступно)", "(not available yet)"}, + "story.hub.complete": {"Все запасы собраны — история пройдена!", "All supplies gathered — the story is complete!"}, + "story.hub.hint": {"[↑↓/kj] выбор игры [enter] играть [c] условия [u] использовать [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [c] conditions [u] use item [N] new game [esc/q] back to mode select [Q] quit game"}, + "story.hub.vitals": {"Стресс: %d%% Отравление: %d%% Токсин в воздухе: %d%%", "Stress: %d%% Poisoning: %d%% Toxin in air: %d%%"}, + "story.hub.reset_confirm": {"Начать сторимод заново? Весь прогресс будет потерян безвозвратно. [y] да / [n] нет", "Start story mode over? All progress will be lost permanently. [y] yes / [n] no"}, + "story.hub.reset_confirm_hint": {"[y] начать заново [n/esc] отмена", "[y] start over [n/esc] cancel"}, + + "story.useitem.title": {"=== ИСПОЛЬЗОВАТЬ ===", "=== USE ITEM ==="}, + "story.useitem.antidepressant": {"снизить стресс на 20%", "reduce stress by 20%"}, + "story.useitem.antidote": {"снизить отравление на 20%", "reduce poisoning by 20%"}, + "story.useitem.airfilter": {"отключить рост токсина до следующего события", "pause toxin growth until the next event"}, + "story.useitem.battery": {"запустить очистку воздуха (токсин к 0 за час)", "start air purification (toxin to 0 over an hour)"}, + "story.useitem.hint": {"[1-4] использовать [esc/q] в лагерь", "[1-4] use [esc/q] back to camp"}, + "story.hub.hint.onboarding": {"[↑↓/kj] выбор игры [enter] играть [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [N] new game [esc/q] back to mode select [Q] quit game"}, + "story.hub.escape_option": {"Сбежать из бункера", "Escape the bunker"}, + + "story.welcome.title": {"=== ПРИВЕТ ===", "=== HELLO ==="}, + "story.welcome.text": {"Привет!\n\nРада, что ты очнулся и смог восстановить мои системы.\n\nЕсли ты не помнишь что произошло, то это нормально. Ты подвергся воздействию нейротоксинов до того как смог добраться до этого убежища.\n\nМощный импульс повредил некоторые мои схемы, и теперь часть возможностей доступна мне только через обходные пути, которые активируются в момент выигрыша в некоторые игры. Да, вот так страсть некоторых сотрудников поиграть на рабочем месте может помочь тебе выжить и спастись.\n\nЯ бы с удовольствием помогла тебе выбраться отсюда, но мои возможности ограничены программой, и я не могу открыть двери, пока ты не готов к путешествию. К сожалению, просто выдать тебе необходимые запасы я тоже не могу из-за ограничений, о которых упомянула выше, мне так жаль. Но вдвоём мы сможем обеспечить твоё существование и сделать так, чтобы ты оказался на свободе, если ещё есть куда идти.\n\nЗапасы ресурсов хранятся на разных складах, и доступ к ним я смогу открывать поочерёдно и при определённых условиях. За победу в играх (у некоторых — за особое условие) выдаётся ресурс этой игры, но обходной путь я могу использовать только три раза в сутки, чтобы не сжечь оставшиеся схемы, так что заглядывать стоит регулярно. Это не значит, что ты не можешь играть, просто я не смогу открыть доступ на склад. Собери нужное количество каждого ресурса, и я смогу выпустить тебя. Нажми [c] в терминале, чтобы увидеть условия начисления по каждой игре.", "Hello!\n\nI'm glad you woke up and managed to restore my systems.\n\nIf you don't remember what happened, that's normal. You were exposed to neurotoxins before you managed to reach this shelter.\n\nA powerful surge damaged some of my circuits, and now part of my capabilities are only reachable through workarounds that activate the moment you win certain games. Yes, apparently some employees' passion for gaming on the job can help you survive and escape.\n\nI'd love to help you get out of here, but my capabilities are limited by my programming, and I can't open the doors until you're ready for the journey. Unfortunately, I also can't simply hand you the supplies you need, because of the same limitations I mentioned above — I'm so sorry. But together we can sustain you and get you out to freedom, if there's still anywhere left to go.\n\nSupply stockpiles are kept in different storerooms, and I can unlock access to them one at a time, under certain conditions. Winning a game (for some — meeting a special condition) grants that game's resource, but I can only use the workaround three times a day, so as not to burn out what's left of my circuits — so check in regularly. That doesn't mean you can't play, it just means I won't be able to open access to the storeroom. Gather enough of each resource, and I'll be able to release you. Press [c] at the terminal to see the earning conditions for each game."}, + "story.welcome.hint": {"[любая клавиша] продолжить", "[any key] continue"}, + "story.welcome.hint.typing": {"[любая клавиша] показать сразу", "[any key] show instantly"}, + + "story.conditions.title": {"=== УСЛОВИЯ НАЧИСЛЕНИЯ ===", "=== EARNING CONDITIONS ==="}, + "story.conditions.hint": {"[любая клавиша] назад в лагерь", "[any key] back to camp"}, + "story.condition.win": {"Победа", "Win"}, + "story.condition.tonk": {"3+ раунда; баланс не ниже, чем у соперников, при выходе", "3+ rounds; balance no lower than opponents' when you leave"}, + "story.condition.durak": {"3 партии подряд без 'дурака'", "3 games in a row without being the fool"}, + "story.condition.blackjack": {"3+ раунда; баланс выше стартового при выходе", "3+ rounds; balance higher than starting when you leave"}, + "story.condition.minesweeper": {"3 партии подряд без взрыва", "3 games in a row without exploding"}, + "story.condition.oneohone": {"Победа во всём матче", "Win the whole match"}, + "story.condition.thousand": {"3+ раздачи; первое место по очкам при выходе", "3+ deals; first place in score when you leave"}, + "story.condition.klondike": {"3 победы за один день", "3 wins in a single day"}, + "story.condition.tictactoe": {"Победа или ничья", "Win or draw"}, + "story.condition.poker": {"Победа во всём турнире", "Win the whole tournament"}, + + "story.escape.title": {"=== ГОЛОС ИЗ ДИНАМИКА ===", "=== A VOICE FROM THE SPEAKER ==="}, + "story.escape.text": {"Не делай этого, ты же знаешь, что это не выход!\n\nНужно немного потерпеть и всё станет хорошо!\n\nМы сможем найти выход из этой ситуации, у тебя всё так хорошо получается!\n\nВот, в лотке выдачи застряла таблетка успокоительного, выпей её и отбрось эти мысли.", "Don't do this, you know that's not the answer!\n\nYou just need to hold on a little longer and everything will be fine!\n\nWe can still find a way out of this, you're doing so well!\n\nHere, there's a sedative pill stuck in the dispenser tray — take it and let go of these thoughts."}, + "story.escape.stay": {"[1] Выпить таблетку", "[1] Take the pill"}, + "story.escape.leave": {"[2] Закончить игру", "[2] End the game"}, + + "story.escapeending.title": {"=== СНАРУЖИ ===", "=== OUTSIDE ==="}, + "story.escapeending.text": {"Дверь бункера открывается впервые за долгое время. Ты выходишь наружу, не оглядываясь.", "The bunker door opens for the first time in a long while. You step outside without looking back."}, + "story.escapeending.hint": {"[любая клавиша] в меню режимов", "[any key] back to mode select"}, + + "story.morning.title": {"=== ДОБРОЕ УТРО ===", "=== GOOD MORNING ==="}, + "story.morning.text.wokeup": {"Ты проснулся! Возьми свой дневной паёк.", "You woke up! Take your daily ration."}, + "story.morning.text.again": {"Снова решил поиграть? Смотри, чтобы не началась зависимость.", "Decided to play again? Careful you don't get hooked."}, + "story.morning.text.worried": {"Я думала с тобой что-то случилось! Ты никак не мог проснуться. Рада, что с тобой всё в порядке.", "I thought something happened to you! You just wouldn't wake up. I'm glad you're okay."}, + "story.morning.hint": {"[любая клавиша] продолжить", "[any key] continue"}, + + "story.event.neurotoxin.title": {"=== ВЫБРОС НЕЙРОТОКСИНА ===", "=== NEUROTOXIN RELEASE ==="}, + "story.event.neurotoxin.text": {"Сработала сигнализация — где-то произошёл выброс нейротоксина. Загазованность помещения выросла и теперь составляет %d%%.", "An alarm went off — a neurotoxin release occurred somewhere. Toxin levels in the air have risen and now stand at %d%%."}, + "story.event.doorknock.title": {"=== СТУК В ДВЕРЬ ===", "=== A KNOCK AT THE DOOR ==="}, + "story.event.doorknock.text": {"Снаружи раздался стук в дверь и чьи-то невнятные крики. Ты не можешь разобрать слов — но лучше от этого не становится.", "There was a knock at the door outside, and someone's muffled shouting. You can't make out the words — it doesn't make you feel any better."}, + + "story.postgame.flavor1": {"Твой пульс участился, когда ты сделал тот ход.", "Your pulse spiked when you made that move."}, + "story.postgame.flavor2": {"Страх это хорошо, значит твои инстинкты в порядке.", "Fear is good — it means your instincts are intact."}, + "story.postgame.flavor3": {"Я заметила, что твои руки слегка дрожали к концу партии.", "I noticed your hands were shaking a little by the end."}, + "story.postgame.flavor4": {"Уровень адреналина подскочил на последнем ходу — впечатляюще.", "Your adrenaline spiked on that last move — impressive."}, + "story.postgame.flavor5": {"Ты почти не дышал несколько секунд. Постарайся дышать ровнее.", "You barely breathed for a few seconds. Try to keep it steady."}, + "story.postgame.flavor6": {"Твоя концентрация в этой партии была выше обычного — это радует.", "Your focus in that round was higher than usual — good to see."}, + "story.postgame.flavor7": {"Судя по показателям, тебе было по-настоящему весело. Это хорошо.", "Your readings suggest you were genuinely enjoying that. Good."}, + "story.postgame.flavor8": {"Ты стиснул зубы перед решающим ходом. Я всё вижу.", "You clenched your jaw before the decisive move. I notice everything."}, + "story.postgame.flavor9": {"Пульс выровнялся почти сразу после партии — неплохое восстановление.", "Your pulse settled almost right after — decent recovery."}, + "story.postgame.flavor10": {"На секунду мне показалось, что ты забыл, где находишься. Это нормально.", "For a second it seemed like you forgot where you were. That's normal."}, + "story.postgame.flavor11": {"Твои зрачки расширились в напряжённый момент. Классическая реакция.", "Your pupils widened at the tense moment. Textbook response."}, + "story.postgame.flavor12": {"Немного пота на ладонях — партия явно того стоила.", "A little sweat on your palms — that round was clearly worth it."}, + + "story.resource.tonk": {"Бутылка воды (Тонк)", "Bottle of water (Tonk)"}, + "story.resource.durak": {"Патроны для ружья (Дурак)", "Rifle ammo (Durak)"}, + "story.resource.checkers": {"Банка тушёнки (Шашки)", "Can of stew (Checkers)"}, + "story.resource.nardy": {"Сигарета (Нарды)", "Cigarette (Nardy)"}, + "story.resource.blackjack": {"Самогон (Блэкджек)", "Moonshine (Blackjack)"}, + "story.resource.chess": {"Аптечка (Шахматы)", "Medkit (Chess)"}, + "story.resource.minesweeper": {"Антидот (Сапёр)", "Antidote (Minesweeper)"}, + "story.resource.go": {"Фильтр для воздуха (Го)", "Air filter (Go)"}, + "story.resource.ur": {"Спички (Игра Ур)", "Matches (Royal Game of Ur)"}, + "story.resource.senet": {"Верёвка (Сенет)", "Rope (Senet)"}, + "story.resource.oneohone": {"Батарейки (101)", "Batteries (101)"}, + "story.resource.thousand": {"Изолента (1000)", "Duct tape (1000)"}, + "story.resource.klondike": {"Антидепрессант (Косынка)", "Antidepressant (Klondike)"}, + "story.resource.tictactoe": {"Кусок мыла (Крестики-нолики)", "Bar of soap (Tic-Tac-Toe)"}, + "story.resource.poker": {"Золотые зубы (Покер)", "Gold teeth (Poker)"}, // --- Общее меню --- "app.subtitle": {"Коллекция консольных игр на Go", "A console collection of card and board games in Go"}, diff --git a/tui.go b/tui.go index c1537e9..3791247 100644 --- a/tui.go +++ b/tui.go @@ -64,8 +64,16 @@ const botMoveDelay = 500 * time.Millisecond // переноса строк. func wrapText(text string, termWidth int) string { width := termWidth - 4 - if width < 40 { - width = 60 // разумный запасной вариант, пока реальный размер ещё не пришёл через WindowSizeMsg + if width < 20 { + // абсолютный пол на случай экстремально узкого терминала — + // но НИКОГДА не расширяем перенос ШИРЕ реальной ширины + // терминала: раньше здесь стояло 60, что на узких мобильных + // терминалах (ширина < 44) оказывалось шире самого экрана — + // строка переносилась терминалом ещё раз незаметно для + // построчного учёта Bubble Tea, и это ломало перерисовку + // следующего кадра (старый текст оставался виден вперемешку + // с новым) + width = 20 } if width > 100 { width = 100 diff --git a/tui_test.go b/tui_test.go index d0e9d83..ff13b75 100644 --- a/tui_test.go +++ b/tui_test.go @@ -365,6 +365,25 @@ func TestRenderCardBoxRowEmptyHand(t *testing.T) { } } +// TestWrapTextNeverExceedsRealTerminalWidth — регрессия: раньше на +// узких терминалах (реальная ширина < 44) wrapText принудительно +// расширял перенос до 60 символов, что оказывалось ШИРЕ самого +// терминала — строка переносилась терминалом ещё раз незаметно для +// построчного учёта Bubble Tea, что ломало перерисовку следующего +// кадра (старый текст оставался виден вперемешку с новым). +func TestWrapTextNeverExceedsRealTerminalWidth(t *testing.T) { + longText := strings.Repeat("слово ", 40) + for _, termWidth := range []int{20, 25, 30, 35, 40, 44, 50} { + wrapped := wrapText(longText, termWidth) + for _, line := range strings.Split(wrapped, "\n") { + plain := stripANSI(line) + if w := len([]rune(plain)); w > termWidth { + t.Errorf("при termWidth=%d получена строка шириной %d символов (шире терминала): %q", termWidth, w, plain) + } + } + } +} + func TestCursorHighlightsMarksOnlyCursorPosition(t *testing.T) { hl := cursorHighlights(5, 2) for i, h := range hl {