Added story mod

This commit is contained in:
Magnus Root 2026-07-15 18:02:34 +03:00
parent a85eb152d1
commit 79e91784d2
22 changed files with 4317 additions and 43 deletions

342
ARCHITECTURE.md Normal file
View file

@ -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. Показатели выживания
Три процентных показателя (0100), видны в лагере только после
завершения знакомства:
- **Стресс** (`Stress`) — не опускается ниже `StoryStressFloor` (20)
сам по себе, но антидепрессант может увести его вплоть до 0, с
последующим часовым восстановлением обратно к 20 (см. 3.8).
- **Отравление** (`Poisoning`).
- **Токсин в воздухе** (`ToxinLevel`).
Ни один из трёх не меняется от одного факта течения времени — только
по конкретным событиям (см. 3.9) и по расходу ресурсов (см. 3.8).
### 3.8. Расходуемые ресурсы — экран `[u]` в лагере
Четыре ресурса имеют не только "цель для сейфа", но и практический
расходуемый эффект (`storyUseItemDefs` в `story_tui.go`):
| Ресурс | Эффект (метод в `story_game.go`) |
|---|---|
| Антидепрессант (Косынка) | `SpendAntidepressant` — стресс 20%, может уйти ниже пола вплоть до 0; если ушёл ниже пола, запускает часовое восстановление обратно к 20% (`ApplyStressRecovery`, линейная интерполяция, как и очистка воздуха ниже) |
| Антидот (Сапёр) | `SpendAntidote` — отравление 20%, не ниже 0 |
| Фильтр для воздуха (Го) | `SpendAirFilter` — отмечает фильтр использованным сегодня (`FilterUsedToday`), что на сегодняшние сутки снимает риск выброса нейротоксина (см. 3.9) и при обработке суток сбрасывает счётчик дней без замены |
| Батарейки (101) | `SpendBattery` — запускает очистку воздуха: токсин плавно идёт к 0 в течение часа (`ApplyAirPurification`) |
Оба "часовых" эффекта (восстановление стресса, очистка воздуха)
устроены одинаково: запоминается начальное значение и момент запуска,
а при каждом входе в лагерь (`enterStoryHub`/
`enterStoryHubFromModeSelect`) линейно интерполируется текущее
значение по прошедшему времени, вплоть до полного завершения через
час.
Трата ресурса (`spendResource`) снимает пометку `Locked`, если после
списания остаток опустился ниже цели — расход реально расходует
накопленное, а не просто "ест" бесплатный излишек сверху.
### 3.9. Суточные события — вероятности
Оба события разыгрываются в `processDayEvents` (`story_game.go`),
по одному разу за каждые виртуальные сутки, обработанные в `Tick`
(если игрок не заходил несколько дней подряд, каждый день
розыгрывается отдельно, а не одним махом).
**Выброс нейротоксина** — зависит от того, сколько суток подряд
фильтр для воздуха НЕ менялся (`DaysSinceFilterUsed`,
`storyNeurotoxinProbability`):
| Суток без замены фильтра | Вероятность выброса за эти сутки |
|---|---|
| 0 (фильтр менялся сегодня) | 0% |
| 1 | 50% |
| 2 | 75% |
| 3 и более | 100% (гарантированно) |
Если выброс произошёл — токсин в воздухе растёт на случайное
значение от 10% до 70% (равномерно, `10 + rnd.Intn(61)`), с
насыщением на 100%. Счётчик дней без фильтра НЕ сбрасывается самим
фактом выброса — только реальной заменой фильтра, так что при долгом
пренебрежении выбросы могут случаться несколько дней подряд один за
другим.
**Стук в дверь** — не зависит ни от какого ресурса, только от того,
сколько суток его не было (`DaysSinceDoorKnock`,
`storyDoorKnockProbability`):
| Суток без стука | Вероятность за эти сутки |
|---|---|
| 0 | 0% |
| 1 | 10% |
| 2 | 50% |
| 3 и более | 100% |
Если событие происходит — стресс +20% (потолок 100%), счётчик
сбрасывается. Этот прирост стресса НЕ уходит сам по себе — снять его
можно только антидепрессантом.
Оба события показываются игроку только при входе через меню режимов
(`enterStoryHubFromModeSelect`) — не при обычном возврате из партии
(`enterStoryHub`), чтобы не прерывать уже идущую игровую сессию
всплывающими уведомлениями после каждой отдельной партии. Флаги
`PendingNeurotoxinAlert`/`PendingDoorKnockAlert` копятся до следующего
входа через меню и показываются по очереди, после приветствия дня.
### 3.10. Приветствие дня
Тоже только при входе через меню режимов, одно из трёх
(`storyGreetingForDays`, по числу суток с прошлого такого входа):
| Суток с прошлого входа | Приветствие |
|---|---|
| 0 (тот же день) | «Снова решил поиграть? Смотри, чтобы не началась зависимость.» |
| 1 | «Ты проснулся! Возьми свой дневной паёк.» |
| 2 и более | «Я думала с тобой что-то случилось!..» |
Плюс при каждом обычном возврате из партии (не через меню) в лагере
показывается одна случайная короткая фраза-наблюдение из пула 12
штук (`storyPostGameFlavorKeys`) — про пульс, дыхание, зрачки и т.п.,
от лица ИИ убежища.
### 3.11. Скрытая концовка
Пункт «Сбежать из бункера» появляется в конце списка игр в лагере,
когда одновременно выполнены **все три** условия
(`storyEscapeUnlocked`):
- Кусок мыла (Крестики-нолики) ≥ 50
- Верёвка (Сенет) ≥ 50
- Стресс ≥ 60%
Выбор пункта ведёт на экран уговоров остаться (`stateStoryEscapeAttempt`) с двумя вариантами: принять успокоительное
(стресс мгновенно падает до 0, без последующего часового
восстановления — в отличие от антидепрессанта) или закончить игру
досрочно (ведёт на финальный экран и обратно в меню режимов;
сохранение сторимода при этом не удаляется).
### 3.12. Сброс прогресса
Клавиша `[N]` в лагере (доступна и на этапе знакомства) запрашивает
подтверждение (`y`/`n`), и по «да» удаляет `story_save.json` и ведёт
на экран выбора сложности заново — тот же паттерн `N` + подтверждение,
что уже был в Трупных батончиках.

View file

@ -73,6 +73,11 @@ type BlackjackGameState struct {
StartingCapital int StartingCapital int
Result *BlackjackResult Result *BlackjackResult
// RoundsPlayed — сколько раундов уже сыграно, включая текущий.
// Нужно только для правила сторимода "минимум 3 раунда, чтобы
// засчиталась победа" — на обычную игру никак не влияет.
RoundsPlayed int
// RoundBetReduced[i] == true, если у игрока i не хватило баланса // RoundBetReduced[i] == true, если у игрока i не хватило баланса
// на полную ставку Bet в этом раунде — тогда он честно ставит // на полную ставку Bet в этом раунде — тогда он честно ставит
// всё, что у него осталось (в том числе 0, если денег нет вовсе), // всё, что у него осталось (в том числе 0, если денег нет вовсе),
@ -157,6 +162,7 @@ func NewBlackjackRound(names []string, humanIdx int, startingCapital, bet int, b
Deck: deck, Deck: deck,
Bet: bet, Bet: bet,
StartingCapital: startingCapital, StartingCapital: startingCapital,
RoundsPlayed: 1,
} }
g.preRoundBalance = make([]int, len(players)) g.preRoundBalance = make([]int, len(players))
@ -351,5 +357,7 @@ func (g *BlackjackGameState) NextRound() *BlackjackGameState {
humanIdx = i 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
} }

View file

@ -23,6 +23,11 @@ type DurakModel struct {
quitting bool quitting bool
backToMenu bool backToMenu bool
// storyNonFoolStreak — сколько подряд партий в этой сессии
// человек НЕ оказывался дураком (сбрасывается при первом же
// поражении). Нужно только сторимоду; на обычную игру не влияет.
storyNonFoolStreak int
} }
// NewDurakModel создаёт новую партию Дурака на numPlayers игроков // NewDurakModel создаёт новую партию Дурака на numPlayers игроков
@ -154,7 +159,13 @@ func (m DurakModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key { switch key {
case "n": case "n":
numPlayers := len(m.game.Players) 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 = NewDurakModel(numPlayers)
m.storyNonFoolStreak = streak
return m, m.maybeScheduleBot() return m, m.maybeScheduleBot()
case "q": case "q":
m.backToMenu = true m.backToMenu = true

10
game.go
View file

@ -99,6 +99,11 @@ type GameState struct {
// уровень, функцию checkBankruptcies в tui.go). // уровень, функцию checkBankruptcies в tui.go).
StartingCapital int StartingCapital int
// RoundsPlayed — сколько раундов уже сыграно, включая текущий.
// Нужно только для правила сторимода "минимум 3 раунда" — на
// обычную игру никак не влияет.
RoundsPlayed int
// preRoundBalance — капитал каждого игрока непосредственно // preRoundBalance — капитал каждого игрока непосредственно
// перед списанием анте в начале раунда. Используется только // перед списанием анте в начале раунда. Используется только
// для вычисления BalanceDeltas в RoundResult (чтобы показать // для вычисления BalanceDeltas в RoundResult (чтобы показать
@ -159,7 +164,9 @@ func (g *GameState) NextRound() *GameState {
isHuman[i] = p.IsHuman isHuman[i] = p.IsHuman
balances[i] = p.Balance 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 — общая внутренняя логика раздачи новой партии/раунда: // newRound — общая внутренняя логика раздачи новой партии/раунда:
@ -191,6 +198,7 @@ func newRound(names []string, isHuman []bool, balances []int, tonkThreshold, sta
AnteAmount: ante, AnteAmount: ante,
TonkMultiplier: tonkMultiplier, TonkMultiplier: tonkMultiplier,
StartingCapital: startingCapital, StartingCapital: startingCapital,
RoundsPlayed: 1,
} }
g.collectAnte() g.collectAnte()
return g return g

BIN
ggc (Stored with Git LFS)

Binary file not shown.

BIN
ggc.exe (Stored with Git LFS)

Binary file not shown.

160
menu.go
View file

@ -225,6 +225,17 @@ type appState int
const ( const (
stateLanguage appState = iota stateLanguage appState = iota
stateModeSelect
stateStoryDifficulty
stateStoryWelcome
stateStoryMorning
stateStoryNeurotoxinAlert
stateStoryDoorKnockAlert
stateStoryHub
stateStoryConditions
stateStoryUseItem
stateStoryEscapeAttempt
stateStoryEscapeEnding
stateMenu stateMenu
stateRulesMenu stateRulesMenu
stateRules stateRules
@ -745,6 +756,16 @@ func (opt menuOption) desc() string {
type AppModel struct { type AppModel struct {
state appState state appState
langCursor int langCursor int
modeCursor int
story *StoryModeState
storyDifficultyCursor int
storyWelcomeWordsShown int
storyGreeting storyGreetingKind
storyPostGameFlavor string
storyConfirmingReset bool
storyHubCursor int
storyActiveResource *StoryResourceID
cursor int cursor int
menuScroll int // индекс первого видимого пункта главного меню menuScroll int // индекс первого видимого пункта главного меню
rulesCursor int rulesCursor int
@ -792,6 +813,13 @@ func (m AppModel) Init() tea.Cmd {
} }
func (m AppModel) Update(msg tea.Msg) (tea.Model, 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 { if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
m.termWidth = sizeMsg.Width m.termWidth = sizeMsg.Width
m.termHeight = sizeMsg.Height m.termHeight = sizeMsg.Height
@ -816,6 +844,28 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updatePlaying(msg) return m.updatePlaying(msg)
case stateLanguage: case stateLanguage:
return m.updateLanguage(msg) 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: case stateRulesMenu:
return m.updateRulesMenu(msg) return m.updateRulesMenu(msg)
case stateRules: case stateRules:
@ -866,7 +916,42 @@ func (m AppModel) updateLanguage(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "enter": case "enter":
SetLanguage(Lang(m.langCursor)) SetLanguage(Lang(m.langCursor))
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 m.state = stateMenu
} else {
if saved, err := LoadStoryModeState(); err == nil {
m.story = saved
m = m.enterStoryHubFromModeSelect()
} else {
m.state = stateStoryDifficulty
}
}
} }
return m, nil return m, nil
} }
@ -1524,6 +1609,39 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
backToMenu = tm.backToMenu backToMenu = tm.backToMenu
} }
if 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.state = stateMenu
m.activeGame = nil m.activeGame = nil
return m, nil return m, nil
@ -1540,6 +1658,28 @@ func (m AppModel) View() string {
return m.activeGame.View() return m.activeGame.View()
case stateLanguage: case stateLanguage:
return m.viewLanguage() 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: case stateRulesMenu:
return m.viewRulesMenu() return m.viewRulesMenu()
case stateRules: case stateRules:
@ -1591,6 +1731,26 @@ func (m AppModel) viewLanguage() string {
return b.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 { func (m AppModel) viewMenu() string {
var b strings.Builder var b strings.Builder
if m.menuUsesCompactBanner() { if m.menuUsesCompactBanner() {

View file

@ -3,6 +3,7 @@ package main
import ( import (
"strings" "strings"
"testing" "testing"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -59,7 +60,9 @@ func TestQReturnsToMenuForAllGames(t *testing.T) {
func newTestAppModel() AppModel { func newTestAppModel() AppModel {
m := NewAppModel() m := NewAppModel()
next, _ := m.Update(key("enter")) next, _ := m.Update(key("enter")) // подтверждаем язык -> экран выбора режима
m = next.(AppModel)
next, _ = m.Update(key("enter")) // подтверждаем "Просто игры" (курсор по умолчанию на нём) -> меню игр
return next.(AppModel) return next.(AppModel)
} }
@ -86,8 +89,8 @@ func TestLanguageScreenSelectEnglish(t *testing.T) {
next, _ = m.Update(key("enter")) next, _ = m.Update(key("enter"))
m = next.(AppModel) m = next.(AppModel)
if m.state != stateMenu { if m.state != stateModeSelect {
t.Fatalf("ожидался переход в stateMenu после выбора языка") t.Fatalf("ожидался переход в stateModeSelect после выбора языка")
} }
if CurrentLang() != LangEN { if CurrentLang() != LangEN {
t.Fatalf("ожидался установленный язык LangEN") t.Fatalf("ожидался установленный язык LangEN")
@ -102,14 +105,84 @@ func TestLanguageScreenDefaultsToRussian(t *testing.T) {
m := NewAppModel() m := NewAppModel()
next, _ := m.Update(key("enter")) next, _ := m.Update(key("enter"))
m = next.(AppModel) m = next.(AppModel)
if m.state != stateMenu { if m.state != stateModeSelect {
t.Fatalf("ожидался переход в stateMenu") t.Fatalf("ожидался переход в stateModeSelect")
} }
if CurrentLang() != LangRU { if CurrentLang() != LangRU {
t.Fatalf("ожидался язык по умолчанию 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) { func TestMenuHasLanguageOption(t *testing.T) {
m := newTestAppModel() m := newTestAppModel()
found := false found := false
@ -1588,3 +1661,57 @@ func TestGameQKeyReturnsToMenuNotQuitsApp(t *testing.T) {
func TestAppModelImplementsTeaModel(t *testing.T) { func TestAppModelImplementsTeaModel(t *testing.T) {
var _ tea.Model = AppModel{} 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)
}
}

View file

@ -29,6 +29,11 @@ type MinesweeperModel struct {
quitting bool quitting bool
backToMenu bool backToMenu bool
// storyWinStreak — сколько подряд партий в этой сессии выиграно
// без взрыва (сбрасывается при первом же поражении). Нужно
// только сторимоду; на обычную игру не влияет.
storyWinStreak int
} }
// NewMinesweeperModel создаёт новую партию width x height с mines // 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 m.game.Result != nil {
if key == "n" { if key == "n" {
if m.game.Result.Won {
m.storyWinStreak++
} else {
m.storyWinStreak = 0
}
m.game = NewMinesweeperGame(m.width, m.height, m.mines) m.game = NewMinesweeperGame(m.width, m.height, m.mines)
m.cursor = MinesweeperPos{Row: m.height / 2, Col: m.width / 2} m.cursor = MinesweeperPos{Row: m.height / 2, Col: m.width / 2}
m.message = "" m.message = ""

1225
story_flow_test.go Normal file

File diff suppressed because it is too large Load diff

519
story_game.go Normal file
View file

@ -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()
}

506
story_game_test.go Normal file
View file

@ -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-х суток)")
}
}

77
story_save.go Normal file
View file

@ -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
}

62
story_save_test.go Normal file
View file

@ -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)
}
}

1096
story_tui.go Normal file

File diff suppressed because it is too large Load diff

BIN
tonk

Binary file not shown.

View file

@ -8,7 +8,103 @@ var translations = map[string]translationEntry{
"lang.title": {"=== ВЫБОР ЯЗЫКА ===", "=== CHOOSE LANGUAGE ==="}, "lang.title": {"=== ВЫБОР ЯЗЫКА ===", "=== CHOOSE LANGUAGE ==="},
"lang.ru": {"Русский", "Русский (Russian)"}, "lang.ru": {"Русский", "Русский (Russian)"},
"lang.en": {"English", "English"}, "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"}, "app.subtitle": {"Коллекция консольных игр на Go", "A console collection of card and board games in Go"},

12
tui.go
View file

@ -64,8 +64,16 @@ const botMoveDelay = 500 * time.Millisecond
// переноса строк. // переноса строк.
func wrapText(text string, termWidth int) string { func wrapText(text string, termWidth int) string {
width := termWidth - 4 width := termWidth - 4
if width < 40 { if width < 20 {
width = 60 // разумный запасной вариант, пока реальный размер ещё не пришёл через WindowSizeMsg // абсолютный пол на случай экстремально узкого терминала —
// но НИКОГДА не расширяем перенос ШИРЕ реальной ширины
// терминала: раньше здесь стояло 60, что на узких мобильных
// терминалах (ширина < 44) оказывалось шире самого экрана —
// строка переносилась терминалом ещё раз незаметно для
// построчного учёта Bubble Tea, и это ломало перерисовку
// следующего кадра (старый текст оставался виден вперемешку
// с новым)
width = 20
} }
if width > 100 { if width > 100 {
width = 100 width = 100

View file

@ -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) { func TestCursorHighlightsMarksOnlyCursorPosition(t *testing.T) {
hl := cursorHighlights(5, 2) hl := cursorHighlights(5, 2)
for i, h := range hl { for i, h := range hl {