diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..f9102b0 --- /dev/null +++ b/README.en.md @@ -0,0 +1,147 @@ +# Go Games Collection + +*[Читать по-русски](README.md)* + +A collection of console card and board games in Go with a text UI (TUI) built on [Bubble Tea](https://github.com/charmbracelet/bubbletea). Play against bots (except solitaire, which is single-player) right in your terminal. + +``` + ____ ___ ____ _ __ __ _____ ____ + / ___|/ _ \ / ___| / \ | \/ | ____/ ___| +| | _| | | | | | _ / _ \ | |\/| | _| \___ \ +| |_| | |_| | | |_| |/ ___ \| | | | |___ ___) | + \____|\___/ \____/_/ \_\_| |_|_____|____/ + ____ ___ _ _ _____ ____ _____ ___ ___ _ _ + / ___/ _ \| | | | | ____/ ___|_ _|_ _/ _ \| \ | | +| | | | | | | | | | _|| | | | | | | | | \| | +| |__| |_| | |___| |___| |__| |___ | | | | |_| | |\ | + \____\___/|_____|_____|_____\____| |_| |___\___/|_| \_| +``` + +## Games in the collection + +| Game | Players | Highlights | +|---|---|---| +| **Tonk** | 1 human + 3 bots | Money system: bankroll, ante, bankruptcy and bot replacement | +| **Durak (Russian Fool)** | 2–6 | Full throw-in variant (any player can throw in, not just the attacker) | +| **Blackjack** | 1–6 (solo works too) | Everyone plays the dealer separately; hit/stand/double | +| **101** | 2–6 | Empty your hand first; reaching 101 penalty points eliminates you | +| **1000 (Thousand)** | strictly 3 | Bidding, the widow, tricks and marriages with a dynamic trump | +| **Klondike Solitaire** | 1 (no bots) | Classic solitaire with move undo | +| **Checkers** | 1 human + bot | Russian draughts, minimax bot with 2 difficulty levels | +| **Chess** | 1 human + bot | Full rules: castling, en passant, checkmate/stalemate; minimax bot with 2 difficulty levels | +| **Corpse-Starch Box** | 1 (no bots) | Incremental game in the spirit of Candy Box 2, Warhammer 40000 flavor; the only game in the collection with a save file | +| **Go** | 1 human + bot | Choice of 9x9/13x13/19x19 board, 3 heuristic bot difficulty levels, area scoring | +| **Minesweeper** | 1 (no bots) | Classic presets (Beginner/Intermediate/Expert), guaranteed-safe first click, chording | +| **Wayfarer** | 1 (no bots) | Text-based space trading inspired by Elite: 512 systems, trading, turn-based combat, 8 story quests; save file | + +Each game comes with its own set of deliberately chosen simplifications where the original has too many regional rule variants (for example, "1000" is only implemented for 3 players, and Durak doesn't include the "ace marriage" or barrel rule). The full list of rules and simplifications is in the app's own "Rules" section or via `--help`. + +## Running it + +Requires Go 1.22+. + +```bash +go run . # open the collection menu +go run . --help # show the rules for every game without entering the TUI +go run . -sim # headless Tonk bot simulation (for balance testing) +go run . -sim-durak # same, for Durak +go run . -sim-blackjack # same, for Blackjack +go run . -sim-101 # same, for 101 +go run . -sim-1000 # same, for Thousand +``` + +Or build a binary: + +```bash +go build -o games . +./games +``` + +## Interface language + +On first launch (and at any point afterward, via the "Язык / Language" menu item) you can choose Russian or English. This switches the entire interface: the menu, settings screens, hints and messages in each of the 12 games, rules text, and bot names. For the CLI, the rules language is set explicitly: `--help` for Russian, `--help-en` for English. + +## General menu controls + +- `↑↓` / `k j` — navigate menu items +- `enter` — select / confirm +- `esc` / `q` — back +- Inside a game, `q` always returns to the menu (it doesn't close the program); `ctrl+c` quits the app entirely + +Some games have a settings screen before they start: ante and bankroll (Tonk), number of players (Durak, 101), number of players + bet (Blackjack), bot difficulty (Checkers). + +Controls for a specific game aren't duplicated here — they're always shown at the bottom of that game's own screen, and are also available in full via the "Rules" menu item or `--help`. + +## Project structure + +Each game is a self-contained set of 3 files (engine / bot / TUI) plus tests; shared infrastructure (menu, cards, deck) lives separately. + +``` +card.go, deck.go — shared card types and the standard 52-card deck +menu.go, banner.go — main menu, ASCII banner, game settings screens +rules.go — rules text for every game (for the TUI and --help) +main.go — entry point, -sim-* flags, --help + +game.go, bot.go, — Tonk: engine, three bot difficulty levels, +bot_heuristics.go, bankruptcy and bot replacement, TUI +tui.go, player.go, +meld.go + +durak_*.go — Durak: engine with full throw-ins, bot, TUI + +blackjack_*.go — Blackjack: dealer engine, bot, TUI + +oneohone_*.go — 101: engine, bot, TUI + +thousand_*.go — Thousand: bidding/tricks/marriages engine, bot, TUI + +klondike_*.go — Klondike: solitaire engine with move undo, TUI + (no bots — there's no one else to play) + +checkers_*.go — Checkers: Russian draughts engine, minimax bot, TUI +chess_*.go — Chess: engine with castling/en passant, minimax bot, TUI +corpsestarch_*.go — Corpse-Starch Box: incremental engine that unlocks + sections as resources accumulate, real-time TUI, + JSON save/load (the only game in the collection with + progress that persists between runs; no bots) +go_*.go — Go: engine with groups/liberties/ko rule/area + scoring, heuristic bot with 3 difficulty levels + (no lookahead search — genuinely strong Go play + needs Monte Carlo/neural nets), choice of + 9x9/13x13/19x19 board, TUI +minesweeper_*.go — Minesweeper: engine with a guaranteed-safe first + move, cascading reveal and chording, three classic + field size presets (no bots) +wayfarer_*.go, — Wayfarer: text-based space trading (galaxy/trading/ +translations_wayfarer*.go combat/quests), inspired by Elite but not a copy of + it; 8 branching story quests, save file (no bots — + opponents inside the game are one-off encounters + during travel, not persistent rivals) + +*_test.go — tests (rules unit tests + stress simulations of + full bot games) +``` + +## Testing + +```bash +go test ./... # the whole package +go test ./... -v # verbose output for every test +``` + +The package has 439 tests: unit tests for game logic (dealing, moves, scoring, edge cases like king captures, a marriage on the first trick, castling out of check or a pinned piece, an actual ko diagram in Go, chording in Minesweeper, full playability of all 8 Wayfarer quests) and separate stress tests that run hundreds to thousands of simulated bot games in a row — these are specifically written to catch rare hangs, card loss/duplication, and other subtle bugs that aren't always visible in a single manual test. + +## Known simplifications + +Traditional card games usually have many regional rule variants. Where a choice was made deliberately (not by mistake), it's explicitly commented in the corresponding `*_game.go` file and repeated in that game's rules text. In short: + +- **Durak** — full rules (any player can throw in, not just the attacker). +- **101** — an ace skips a turn, a 6/7/king of spades gives penalty cards; a 10 doesn't reverse turn order. +- **1000** — only for 3 players; no barrel, golden hand, "samosval," ace marriage, or blind play. +- **Klondike** — draws one card at a time, unlimited redeals; no returning cards from the foundation back into play. +- **Checkers** — Russian rules (a man captures in any direction, "flying" kings), no official 15/30-move draw rules — replaced with a simple limit of 60 half-moves without a capture. +- **Chess** — full rules, including castling (with attacked-square checks) and en passant; pawn promotion is always automatic to a queen (no piece choice); threefold repetition isn't implemented — replaced with a simplified limit on half-moves without a capture or pawn move. +- **Corpse-Starch Box** — not a card game, but an incremental (idle/clicker) genre in the spirit of Candy Box 2, reimagined in a grim far-future setting; the only game in the collection with a save file (auto-saving every 15 seconds and on quit), with an honest accrual of passive income for the time the game was closed. +- **Go** — the basic rules (liberties, capturing, suicidal moves, simple ko) are fully implemented; NOT implemented: a dead-stone-removal phase before scoring (you need to actually capture hopeless enemy stones before passing twice, or the score will be wrong) and triple position repetition (superko). The bot at all three difficulty levels is heuristic, with no lookahead search through the game tree: genuinely strong Go play requires Monte Carlo search or neural-network position evaluation, which is beyond the scope of this project. +- **Minesweeper** — the first move is guaranteed safe not just for itself but for all its neighbors too (a bit more generous than the classic rule, which only excludes the clicked cell itself) — done deliberately so the first move usually opens up a noticeable area right away instead of a single bare number. +- **Wayfarer** — inspired by Elite, but deliberately not a copy of it: its own (not byte-for-byte) galaxy generation algorithm, and no honest real-time 3D flight — travel and dangerous encounters are resolved through text with choices instead of flying a cockpit. The name and all terminology are original, to avoid overlapping with the Elite trademark. Easter egg: just like the original, where you could change the generator's seed numbers in the code, here you can regenerate the entire galaxy by creating a `.env` file next to the executable with the line `WAYFARER_GALAXY_SEED=` — no code changes needed. diff --git a/README.md b/README.md index 7ba8ba5..921192d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Go Games Collection +*[Read this in English](README.en.md)* + Коллекция консольных карточных игр и настольных игр на Go с текстовым интерфейсом (TUI) на [Bubble Tea](https://github.com/charmbracelet/bubbletea). Играете против ботов (кроме пасьянса — он на одного) прямо в терминале. ``` @@ -26,6 +28,11 @@ | **1000 (Тысяча)** | строго 3 | Торги, прикуп, взятки и марьяжи с динамическим козырем | | **Косынка (Klondike)** | 1 (без ботов) | Классический пасьянс с отменой хода | | **Шашки (Checkers)** | 1 человек + бот | Русские шашки, бот на минимаксе с 2 уровнями сложности | +| **Шахматы (Chess)** | 1 человек + бот | Полные правила: рокировка, взятие на проходе, мат/пат; бот на минимаксе с 2 уровнями сложности | +| **Трупные батончики (Corpse-Starch Box)** | 1 (без ботов) | Инкрементальная игра в духе Candy Box 2, антураж Warhammer 40000; единственная игра коллекции с сохранением на диск | +| **Го (Go)** | 1 человек + бот | Доска 9x9/13x13/19x19 на выбор, 3 уровня сложности эвристического бота, площадной подсчёт очков | +| **Сапёр (Minesweeper)** | 1 (без ботов) | Классические пресеты (Новичок/Любитель/Эксперт), гарантированно безопасный первый ход, хорда | +| **Странник (Wayfarer)** | 1 (без ботов) | Текстовая космоторговля в духе Elite: 512 систем, торговля, пошаговый бой, 8 сюжетных квестов; сохранение на диск | Каждая игра — со своим набором специально подобранных упрощений там, где у оригинала слишком много вариантов правил (например, «1000» реализована только на троих, а Дурак — без «тузового марьяжа» и бочки). Полный список правил и упрощений — в разделе «Правила» самого приложения или через `--help`. @@ -50,6 +57,10 @@ go build -o games . ./games ``` +## Язык интерфейса + +При первом запуске (и в любой момент позже — через пункт «Язык / Language» в главном меню) можно выбрать русский или английский. Меняется весь интерфейс: меню, экраны настроек, подсказки и сообщения в каждой из 12 игр, тексты правил и имена ботов. Для CLI-режима язык правил задаётся явно: `--help` — русский, `--help-en` — английский. + ## Общее управление в меню - `↑↓` / `k j` — навигация по пунктам меню @@ -88,6 +99,23 @@ klondike_*.go — Косынка: движок пасьянса с о (ботов нет — играть некому, кроме человека) checkers_*.go — Шашки: движок русских шашек, бот на минимаксе, TUI +chess_*.go — Шахматы: движок с рокировкой/взятием на проходе, бот на минимаксе, TUI +corpsestarch_*.go — Трупные батончики: инкрементальный движок с открытием + разделов по накоплению ресурса, TUI на реальном времени, + сохранение/загрузка в JSON (единственная игра коллекции + с прогрессом между запусками; без ботов) +go_*.go — Го: движок с группами/свободами/правилом ко/площадным + подсчётом очков, эвристический бот на 3 уровня сложности + (без поиска вперёд — честная сильная игра требует + Monte-Carlo/нейросети), доска 9x9/13x13/19x19 на выбор, TUI +minesweeper_*.go — Сапёр: движок с гарантированно безопасным первым ходом, + каскадным открытием и хордой, три классических пресета + размера поля (без ботов) +wayfarer_*.go, — Странник: текстовая космоторговля (галактика/торговля/ +translations_wayfarer*.go бой/квесты), вдохновлена Elite, но не копирует её; 8 + сюжетных квестов с ветвлениями, сохранение на диск + (без ботов — соперники внутри игры не постоянные, а + разовые встречи в пути) *_test.go — тесты (юнит-тесты правил + стресс-симуляции полных партий ботами) @@ -100,7 +128,7 @@ go test ./... # весь пакет go test ./... -v # подробный вывод по каждому тесту ``` -В пакете 215 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой или марьяжа на первой взятке) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте. +В пакете 439 тестов: юнит-тесты игровой логики (раздача, ходы, подсчёт очков, крайние случаи вроде взятия дамкой, марьяжа на первой взятке, рокировки под шахом или связанной фигуры, реальной диаграммы ко в Го, хорды в Сапёре, полной прохождимости всех 8 квестов Странника) и отдельные стресс-тесты, прогоняющие сотни-тысячи симулированных партий ботами подряд — они специально написаны, чтобы ловить редкие зависания, потерю/дублирование карт и прочие тонкие баги, которые не всегда видны в единичном ручном тесте. ## Известные упрощения @@ -111,3 +139,8 @@ go test ./... -v # подробный вывод по каждому те - **1000** — только на 3 игроков; нет бочки, золотого кона, самосвала, тузового марьяжа и игры втёмную. - **Косынка** — добор по одной карте, неограниченные пересдачи; возврата карт с фундамента в игру нет. - **Шашки** — русские правила (взятие в любом направлении простой шашкой, «летающая» дамка), без официальных 15/30-ходовых правил ничьей — вместо них простой лимит в 60 полуходов без взятий. +- **Шахматы** — полные правила, включая рокировку (с проверкой атакованных полей) и взятие на проходе; превращение пешки всегда автоматически в ферзя (без выбора фигуры); троекратное повторение позиции не реализовано — вместо него упрощённый лимит по числу полуходов без взятий/ходов пешками. +- **Трупные батончики** — не карточная игра, а инкрементальный (idle/clicker) жанр в духе Candy Box 2, переосмысленный в антураже мрачного далёкого будущего; единственная игра коллекции с сохранением на диск (автоматически каждые 15 секунд и при выходе), с честным начислением пассивного дохода за время, пока партия была закрыта. +- **Го** — базовые правила (свободы, взятие, самоубийственный ход, простое ко) реализованы полностью; НЕ реализованы: фаза удаления мёртвых камней перед подсчётом (нужно реально взять безнадёжные камни соперника до двух пасов подряд, иначе счёт будет неверным) и тройное повторение позиции (супер-ко). Бот на всех трёх уровнях сложности — эвристический, без поиска вперёд по дереву партии: честная сильная игра в Го требует Monte-Carlo поиска или нейросетевой оценки позиции, что выходит за рамки этого проекта. +- **Сапёр** — первый ход гарантированно безопасен не только сам по себе, но и все его соседи (чуть щедрее классического правила, где исключается только сама кликнутая клетка) — сделано специально, чтобы первый ход обычно сразу открывал заметную область, а не голую цифру. +- **Странник** — вдохновлён Elite, но сознательно не копирует её: собственный (не побайтовый) алгоритм генерации галактики, честного 3D-полёта в реальном времени нет — перелёты и опасные встречи разрешаются текстом с выбором вместо полёта в кокпите. Название и вся терминология авторские, чтобы не пересекаться с товарным знаком Elite. Пасхалка: как и в оригинале, где числа генератора можно было поменять в коде, здесь всю галактику можно перегенерировать заново, создав `.env` рядом с исполняемым файлом со строкой `WAYFARER_GALAXY_SEED=<число>` — без правки кода коллекции. diff --git a/blackjack_tui.go b/blackjack_tui.go index 4c2dcb3..3289c52 100644 --- a/blackjack_tui.go +++ b/blackjack_tui.go @@ -34,7 +34,7 @@ func NewBlackjackModel(numPlayers, startingCapital, bet int) BlackjackModel { if numPlayers > 6 { numPlayers = 6 } - names := []string{"Вы"} + names := []string{T("common.you")} if numPlayers > 1 { botNames, _ := newBotRoster(numPlayers - 1) names = append(names, botNames...) @@ -67,7 +67,7 @@ func (m *BlackjackModel) setInfo(format string, args ...interface{}) { } func (m *BlackjackModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -90,10 +90,10 @@ func (m BlackjackModel) handleBotMove() (tea.Model, tea.Cmd) { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { - m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[idx].Name, err)) return m, nil } - m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[idx].Name) return m, m.maybeScheduleBot() } @@ -130,21 +130,21 @@ func (m BlackjackModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы взяли карту.") + m.setInfo(T("blackjack.msg.hit")) return m, m.maybeScheduleBot() case "s": if err := m.game.Stand(); err != nil { m.setError(err) return m, nil } - m.setInfo("Вы остановились.") + m.setInfo(T("blackjack.msg.stand")) return m, m.maybeScheduleBot() case "d": if err := m.game.DoubleDown(); err != nil { m.setError(err) return m, nil } - m.setInfo("Вы удвоили ставку.") + m.setInfo(T("blackjack.msg.double")) return m, m.maybeScheduleBot() } return m, nil @@ -154,7 +154,7 @@ func (m BlackjackModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func renderHandCards(hand []Card) string { if len(hand) == 0 { - return dimStyle.Render("(нет карт)") + return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { @@ -166,13 +166,13 @@ func renderHandCards(hand []Card) string { func statusLabel(s BlackjackStatus) string { switch s { case BJStatusStood: - return "стоп" + return T("blackjack.status.stood") case BJStatusBusted: - return "перебор" + return T("blackjack.status.busted") case BJStatusBlackjack: - return "блэкджек!" + return T("blackjack.status.blackjack") default: - return "ходит" + return T("blackjack.status.playing") } } @@ -184,11 +184,8 @@ func (m BlackjackModel) renderPlayers() string { marker = "> " } name := p.Name - if i == m.humanIdx { - name += " (вы)" - } total := handTotal(p.Hand) - line := fmt.Sprintf("%s%-20s ставка: %-4d карты: %s (%d) [%s]", + line := fmt.Sprintf(T("blackjack.player_line"), marker, name, p.Bet, renderHandCards(p.Hand), total, statusLabel(p.Status)) if m.game.Phase == BJPhasePlayerTurn && i == m.game.CurrentPlayerIdx { line = turnStyle.Render(line) @@ -204,32 +201,32 @@ func (m BlackjackModel) renderDealer() string { hidden := dimStyle.Render("??") return fmt.Sprintf("%s %s", renderCard(m.game.Dealer[0]), hidden) } - return fmt.Sprintf("%s (%d)", renderHandCards(m.game.Dealer), handTotal(m.game.Dealer)) + return fmt.Sprintf(T("blackjack.dealer_result"), renderHandCards(m.game.Dealer), handTotal(m.game.Dealer)) } func (m BlackjackModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== БЛЭКДЖЕК ===")) + fmt.Fprintln(&b, titleStyle.Render(T("blackjack.title"))) fmt.Fprintln(&b) - fmt.Fprintf(&b, "Дилер: %s\n\n", m.renderDealer()) + fmt.Fprintf(&b, T("blackjack.dealer_line"), m.renderDealer()) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) if m.game.Phase == BJPhaseRoundOver { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) - fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("blackjack.hint.gameover"))) return b.String() } if m.game.CurrentPlayerIdx == m.humanIdx { - fmt.Fprintln(&b, "[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню") + fmt.Fprintln(&b, T("blackjack.hint.turn")) } else { - fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayerIdx].Name) + fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.game.CurrentPlayerIdx].Name) } fmt.Fprintln(&b) @@ -247,19 +244,19 @@ func (m BlackjackModel) View() string { func (m BlackjackModel) renderResult() string { res := m.game.Result var b strings.Builder - dealerLine := fmt.Sprintf("Дилер: %s (%d)", renderHandCards(m.game.Dealer), res.DealerTotal) + dealerLine := fmt.Sprintf(T("blackjack.dealer_result"), renderHandCards(m.game.Dealer), res.DealerTotal) if res.DealerBusted { - dealerLine += " — перебор!" + dealerLine += T("blackjack.busted_suffix") } fmt.Fprintln(&b, dealerLine) fmt.Fprintln(&b) outcomeText := map[string]string{ - "blackjack": "блэкджек! выигрыш 3:2", - "win": "выигрыш", - "push": "ничья, ставка возвращена", - "lose": "проигрыш", - "bust": "перебор, проигрыш", + "blackjack": T("blackjack.outcome.blackjack"), + "win": T("blackjack.outcome.win"), + "push": T("blackjack.outcome.push"), + "lose": T("blackjack.outcome.lose"), + "bust": T("blackjack.outcome.bust"), } for i, p := range m.game.Players { r := res.PlayerResults[i] @@ -269,7 +266,7 @@ func (m BlackjackModel) renderResult() string { } else if r.Delta > 0 { style = winStyle } - fmt.Fprintln(&b, style.Render(fmt.Sprintf(" %-20s %-24s изменение капитала: %+d (итого: %d)", + fmt.Fprintln(&b, style.Render(fmt.Sprintf(T("blackjack.result_line"), p.Name, outcomeText[r.Outcome], r.Delta, p.Balance))) } return strings.TrimRight(b.String(), "\n") diff --git a/bot.go b/bot.go index fff52ff..e91144d 100644 --- a/bot.go +++ b/bot.go @@ -9,6 +9,28 @@ type BotStrategy interface { PlayTurn(g *GameState) error } +// dropRiskThreshold — вес руки, ниже которого боты готовы рискнуть +// дропом. Строже порога Тонка (см. DefaultTonkThreshold), потому что +// у дропа, в отличие от Тонка, нет встроенной защиты от ошибки: +// объявить его может каждый в начале своего хода, и при ошибке +// придётся доплатить штраф. Бот при этом решает только по своей +// руке — он не подглядывает в карты соперников, как и человек. +const dropRiskThreshold = 3 + +// maybeBotDrop проверяет, стоит ли боту рискнуть дропом прямо перед +// взятием карты (единственный момент, когда дроп разрешён). Если да +// — объявляет его и возвращает true (ход на этом закончен). +func maybeBotDrop(g *GameState) (bool, error) { + if g.Phase != PhaseDraw { + return false, nil + } + if HandValue(g.current().Hand) > dropRiskThreshold { + return false, nil + } + _, err := g.Drop() + return true, err +} + // botLevel — запись в реестре доступных уровней сложности ботов: // используется и для явного назначения бота на место, и для // случайного выбора (например, при замене выбывшего игрока). @@ -37,19 +59,32 @@ func randomBotStrategy() (BotStrategy, string) { // знать, с кем имеет дело. var botNamePool = []string{"Костоправ", "Гоблин", "Одноглазый", "Ворон", "Ильмо"} -// pickBotName выбирает случайное имя из botNamePool, по возможности -// не повторяющее уже занятые имена (переданные в used). Если все -// имена из пула уже заняты, допускает повтор — это лучше, чем -// оставить место вовсе без имени. +// botNamePoolEN — те же самые персонажи, но по-английски: смысловые +// прозвища переведены (Костоправ -> Bonesetter и т.д.), а не просто +// транслитерированы, чтобы сохранить характер имён. +var botNamePoolEN = []string{"Bonesetter", "Goblin", "One-Eye", "Raven", "Ilmo"} + +func activeBotNamePool() []string { + if CurrentLang() == LangEN { + return botNamePoolEN + } + return botNamePool +} + +// pickBotName выбирает случайное имя из активного (для текущего +// языка) пула имён, по возможности не повторяющее уже занятые имена +// (переданные в used). Если все имена из пула уже заняты, допускает +// повтор — это лучше, чем оставить место вовсе без имени. func pickBotName(used map[string]bool) string { - free := make([]string, 0, len(botNamePool)) - for _, name := range botNamePool { + pool := activeBotNamePool() + free := make([]string, 0, len(pool)) + for _, name := range pool { if !used[name] { free = append(free, name) } } if len(free) == 0 { - free = botNamePool + free = pool } return free[rand.Intn(len(free))] } @@ -84,6 +119,9 @@ func newBotRoster(n int) (names []string, strategies []BotStrategy) { type SimpleBot struct{} func (SimpleBot) PlayTurn(g *GameState) error { + if dropped, err := maybeBotDrop(g); dropped { + return err + } if err := simpleDrawDecision(g); err != nil { return err } @@ -153,6 +191,9 @@ func chooseDiscardSimple(hand []Card) Card { type MediumBot struct{} func (MediumBot) PlayTurn(g *GameState) error { + if dropped, err := maybeBotDrop(g); dropped { + return err + } if err := mediumDrawDecision(g); err != nil { return err } @@ -209,6 +250,9 @@ type TonkBot struct { } func (b TonkBot) PlayTurn(g *GameState) error { + if dropped, err := maybeBotDrop(g); dropped { + return err + } if err := mediumDrawDecision(g); err != nil { return err } diff --git a/checkers_tui.go b/checkers_tui.go index 1787969..303120b 100644 --- a/checkers_tui.go +++ b/checkers_tui.go @@ -10,12 +10,14 @@ import ( ) var ( - checkersDarkSquare = lipgloss.NewStyle().Background(lipgloss.Color("236")) - checkersLightSquare = lipgloss.NewStyle().Background(lipgloss.Color("94")) - checkersWhitePiece = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - checkersBlackPiece = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Bold(true) - checkersCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("220")).Foreground(lipgloss.Color("16")).Bold(true) - checkersSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("39")).Foreground(lipgloss.Color("16")).Bold(true) + checkersDarkSquare = lipgloss.NewStyle().Background(lipgloss.Color("236")) + checkersLightSquare = lipgloss.NewStyle().Background(lipgloss.Color("180")) + checkersWhitePieceColor = lipgloss.Color("255") + checkersBlackPieceColor = lipgloss.Color("124") + checkersWhitePiece = lipgloss.NewStyle().Foreground(checkersWhitePieceColor).Bold(true) + checkersBlackPiece = lipgloss.NewStyle().Foreground(checkersBlackPieceColor).Bold(true) + checkersCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("82")).Foreground(lipgloss.Color("16")).Bold(true) + checkersSelStyle = lipgloss.NewStyle().Background(lipgloss.Color("39")).Foreground(lipgloss.Color("16")).Bold(true) ) // CheckersModel — TUI поверх CheckersGameState. Человек всегда @@ -66,7 +68,7 @@ func (m *CheckersModel) setInfo(format string, args ...interface{}) { } func (m *CheckersModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -88,7 +90,7 @@ func (m CheckersModel) handleBotMove() (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Бот сходил.") + m.setInfo(T("checkers.msg.bot_moved")) return m, m.maybeScheduleBot() } @@ -173,7 +175,7 @@ func (m CheckersModel) handleSelectOrMove() (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Ход выполнен.") + m.setInfo(T("checkers.msg.moved")) if m.game.ChainPos != nil { pos := *m.game.ChainPos m.selected = &pos @@ -186,19 +188,31 @@ func (m CheckersModel) handleSelectOrMove() (tea.Model, tea.Cmd) { // --- Отрисовка ----------------------------------------------------- -func pieceGlyph(p *CheckersPiece) string { +// pieceGlyphChar возвращает "сырой" (без цвета) символ фигуры — +// используется вместе с pieceForegroundColor, чтобы построить один +// комбинированный стиль (фон клетки + цвет фигуры) вместо вложенных +// вызовов Render(), которые ломаются в некоторых терминалах (см. +// аналогичный баг, исправленный ранее в отрисовке карт Тонка). +func pieceGlyphChar(p *CheckersPiece) string { if p == nil { return " " } - glyph := "o" if p.King { - glyph = "O" + return "O" + } + return "o" +} + +// pieceForegroundColor возвращает цвет текста фигуры (второе +// значение — false, если клетка пуста и раскрашивать нечего). +func pieceForegroundColor(p *CheckersPiece) (lipgloss.Color, bool) { + if p == nil { + return "", false } - style := checkersBlackPiece if p.Color == CheckersWhite { - style = checkersWhitePiece + return checkersWhitePieceColor, true } - return style.Render(glyph) + return checkersBlackPieceColor, true } func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool { @@ -224,17 +238,23 @@ func (m CheckersModel) renderBoard() string { if (r+c)%2 == 1 { cellStyle = checkersDarkSquare } - glyph := pieceGlyph(m.game.Board[r][c]) switch { + case m.cursor == pos: + cellStyle = checkersCursorStyle case m.selected != nil && *m.selected == pos: cellStyle = checkersSelStyle case dests[pos]: cellStyle = checkersSelStyle - case m.cursor == pos: - cellStyle = checkersCursorStyle } - fmt.Fprint(&b, cellStyle.Render(" "+glyph+" ")) + + piece := m.game.Board[r][c] + text := pieceGlyphChar(piece) + combined := cellStyle + if fg, ok := pieceForegroundColor(piece); ok { + combined = cellStyle.Foreground(fg).Bold(true) + } + fmt.Fprint(&b, combined.Render(" "+text+" ")) } fmt.Fprintln(&b) } @@ -243,42 +263,43 @@ func (m CheckersModel) renderBoard() string { func (m CheckersModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== ШАШКИ ===")) + fmt.Fprintln(&b, titleStyle.Render(T("checkers.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderBoard()) + fmt.Fprintln(&b, dimStyle.Render(T("checkers.legend"))) fmt.Fprintln(&b) if m.game.Result != nil { res := m.game.Result switch { case res.Draw: - fmt.Fprintln(&b, errorStyle.Render("Ничья (слишком долго не было взятий).")) + fmt.Fprintln(&b, errorStyle.Render(T("checkers.result.draw"))) case res.Winner == m.human: - fmt.Fprintln(&b, winStyle.Render("Вы выиграли!")) + fmt.Fprintln(&b, winStyle.Render(T("checkers.result.win"))) default: - fmt.Fprintln(&b, errorStyle.Render("Победил бот.")) + fmt.Fprintln(&b, errorStyle.Render(T("checkers.result.lose"))) } fmt.Fprintln(&b) - fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("checkers.hint.gameover"))) return b.String() } - turnLabel := "Ваш ход (белые)" + turnLabel := T("checkers.turn.you") if m.game.Turn != m.human { - turnLabel = "Бот думает (чёрные)..." + turnLabel = T("checkers.turn.bot") } fmt.Fprintln(&b, turnLabel) fmt.Fprintln(&b) if m.game.Turn == m.human { if m.selected == nil { - fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] выбрать шашку [q] в меню") + fmt.Fprintln(&b, T("checkers.hint.noselect")) } else { - fmt.Fprintln(&b, "[↑↓←→/hjkl] курсор [enter] переместить (или отменить выбор) [esc] отменить выбор") + fmt.Fprintln(&b, T("checkers.hint.selected")) } } diff --git a/checkers_tui_test.go b/checkers_tui_test.go index 9754140..6fe12f7 100644 --- a/checkers_tui_test.go +++ b/checkers_tui_test.go @@ -1,6 +1,73 @@ package main -import "testing" +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +// TestCheckersModelCursorVisibleOnLegalDestination проверяет тот же +// баг, что и в Шахматах: курсор не должен теряться под подсветкой +// легального хода. +func TestCheckersModelCursorVisibleOnLegalDestination(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + m := NewCheckersModel(0) + m.cursor = CheckersPos{Row: 5, Col: 2} + next, _ := m.Update(key("enter")) // выбираем шашку + m = next.(CheckersModel) + + dests := m.legalDestinationSet() + var destPos CheckersPos + for pos := range dests { + destPos = pos + break + } + m.cursor = destPos + + board := m.renderBoard() + cursorTag := checkersCursorStyle.Render("X") + start := strings.Index(cursorTag, "\x1b[") + end := strings.Index(cursorTag, "m") + cursorColorCode := cursorTag[start : end+1] + if !strings.Contains(board, cursorColorCode) { + t.Errorf("цвет курсора должен присутствовать в отрисовке доски даже на клетке легального хода") + } +} + +// TestCheckersCursorOnPieceUsesOneCombinedStyle проверяет исправление +// более глубокого бага: раньше цвет фигуры применялся ВНУТРИ уже +// готового Render() вызова, что при последующем оборачивании в фон +// клетки (курсор/подсветка) давало вложенные ANSI-последовательности +// с преждевременным сбросом — фон обрывался до конца клетки (тот же +// класс бага, что раньше был в отрисовке карт Тонка). +func TestCheckersCursorOnPieceUsesOneCombinedStyle(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + piece := &CheckersPiece{Color: CheckersBlack} + text := pieceGlyphChar(piece) + fg, ok := pieceForegroundColor(piece) + if !ok { + t.Fatalf("ожидался цвет для непустой клетки") + } + combined := checkersCursorStyle.Foreground(fg).Bold(true) + rendered := combined.Render(" " + text + " ") + + resets := strings.Count(rendered, "\x1b[0m") + if resets != 1 { + t.Errorf("ожидался ровно один сброс ANSI на всю клетку (единый стиль), получено %d: %q", resets, rendered) + } + opens := strings.Count(rendered, "\x1b[") + if opens != 2 { + t.Errorf("ожидалось ровно 2 вхождения ESC (открытие+закрытие, без вложенности), получено %d: %q", opens, rendered) + } +} func TestCheckersModelSelectAndMoveViaKeys(t *testing.T) { m := NewCheckersModel(0) diff --git a/chess_bot.go b/chess_bot.go new file mode 100644 index 0000000..d2de0d4 --- /dev/null +++ b/chess_bot.go @@ -0,0 +1,181 @@ +package main + +import "math" + +// ChessBot — бот на основе минимакса с альфа-бета отсечением. +// Ветвление в шахматах гораздо шире, чем в шашках, поэтому глубина +// поиска по умолчанию заметно меньше. +type ChessBot struct { + Depth int // глубина поиска в полуходах; 0 -> используется значение по умолчанию +} + +// Уровни сложности: разница только в глубине поиска. +const ( + ChessDifficultyEasy = 2 // "Простой" — быстрый, играет более-менее случайно с точки зрения стратегии + ChessDifficultyHard = 4 // "Сильный" — заметно сильнее, всё ещё отвечает быстро +) + +const defaultChessDepth = 3 + +// chessMoveStep — один ход (from->to), достаточный для одного +// вызова g.Move. +type chessMoveStep struct { + From ChessPos + To ChessPos +} + +// legalChessSteps перечисляет все допустимые ходы для стороны color. +func legalChessSteps(g *ChessGameState, color ChessColor) []chessMoveStep { + var out []chessMoveStep + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil || p.Color != color { + continue + } + pos := ChessPos{r, c} + for _, opt := range g.LegalMovesFrom(pos) { + out = append(out, chessMoveStep{From: pos, To: opt.To}) + } + } + } + return out +} + +var chessPieceValue = map[ChessPieceType]int{ + ChessPawn: 100, ChessKnight: 320, ChessBishop: 330, ChessRook: 500, ChessQueen: 900, ChessKing: 0, +} + +// chessCenterBonus — небольшой позиционный бонус за контроль центра +// (тем же духом, что и бонус за продвижение шашек к дамке): не +// претендует на глубокое понимание позиции, просто чуть предпочитает +// активные фигуры пассивным. +func chessCenterBonus(pos ChessPos) int { + dr := pos.Row - 3 + if dr < 0 { + dr = -dr + } + dc := pos.Col - 3 + if dc < 0 { + dc = -dc + } + dist := dr + dc + return 6 - dist +} + +// evaluateChess оценивает позицию с точки зрения perspective: +// положительно — хорошо для perspective. +func evaluateChess(g *ChessGameState, perspective ChessColor) int { + if g.Result != nil { + if g.Result.Draw { + return 0 + } + if g.Result.Winner == perspective { + return 1000000 + } + return -1000000 + } + score := 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil { + continue + } + value := chessPieceValue[p.Type] + chessCenterBonus(ChessPos{r, c}) + if p.Color == perspective { + score += value + } else { + score -= value + } + } + } + return score +} + +func minimaxChess(g *ChessGameState, depth int, alpha, beta int, perspective ChessColor) int { + if g.Result != nil || depth == 0 { + return evaluateChess(g, perspective) + } + steps := legalChessSteps(g, g.Turn) + if len(steps) == 0 { + return evaluateChess(g, perspective) + } + + maximizing := g.Turn == perspective + if maximizing { + best := math.MinInt32 + for _, step := range steps { + child := cloneChess(g) + _ = child.Move(step.From, step.To) + val := minimaxChess(child, depth-1, alpha, beta, perspective) + if val > best { + best = val + } + if best > alpha { + alpha = best + } + if alpha >= beta { + break + } + } + return best + } + + best := math.MaxInt32 + for _, step := range steps { + child := cloneChess(g) + _ = child.Move(step.From, step.To) + val := minimaxChess(child, depth-1, alpha, beta, perspective) + if val < best { + best = val + } + if best < beta { + beta = best + } + if alpha >= beta { + break + } + } + return best +} + +// DecideMove выбирает лучший ход для текущего хода в g согласно +// минимаксу. +func (b ChessBot) DecideMove(g *ChessGameState) (chessMoveStep, bool) { + steps := legalChessSteps(g, g.Turn) + if len(steps) == 0 { + return chessMoveStep{}, false + } + depth := b.Depth + if depth <= 0 { + depth = defaultChessDepth + } + + perspective := g.Turn + bestVal := math.MinInt32 + var bestStep chessMoveStep + alpha, beta := math.MinInt32, math.MaxInt32 + for _, step := range steps { + child := cloneChess(g) + _ = child.Move(step.From, step.To) + val := minimaxChess(child, depth-1, alpha, beta, perspective) + if val > bestVal { + bestVal = val + bestStep = step + } + if bestVal > alpha { + alpha = bestVal + } + } + return bestStep, true +} + +// PlayFullTurn исполняет один ход бота. +func (b ChessBot) PlayFullTurn(g *ChessGameState) error { + step, ok := b.DecideMove(g) + if !ok { + return nil + } + return g.Move(step.From, step.To) +} diff --git a/chess_bot_test.go b/chess_bot_test.go new file mode 100644 index 0000000..9de6f71 --- /dev/null +++ b/chess_bot_test.go @@ -0,0 +1,61 @@ +package main + +import "testing" + +// TestChessBotFullGames прогоняет несколько полных партий бота +// против самого себя (небольшая глубина поиска — ради скорости +// теста), проверяя отсутствие паник, зависаний и что итог всегда +// корректен (мат, пат либо иная ничья). +func TestChessBotFullGames(t *testing.T) { + bot := ChessBot{Depth: 2} + + for game := 0; game < 5; game++ { + g := NewChessGame() + + const maxSteps = 600 + steps := 0 + for ; steps < maxSteps && g.Result == nil; steps++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("игра %d, шаг %d: ошибка бота: %v", game, steps, err) + } + } + if steps >= maxSteps { + t.Fatalf("игра %d: партия не завершилась за %d шагов (возможное зависание)", game, maxSteps) + } + if g.Result == nil { + t.Fatalf("игра %d: партия завершилась без результата", game) + } + + whiteKings, blackKings := 0, 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + if p := g.Board[r][c]; p != nil && p.Type == ChessKing { + if p.Color == ChessWhite { + whiteKings++ + } else { + blackKings++ + } + } + } + } + if whiteKings != 1 || blackKings != 1 { + t.Fatalf("игра %d: на доске должно быть ровно по одному королю каждого цвета, получено white=%d black=%d", + game, whiteKings, blackKings) + } + } +} + +func TestChessBotHandlesCastlingAndPromotion(t *testing.T) { + bot := ChessBot{Depth: 1} + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.Board[1][0] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + + for i := 0; i < 10 && g.Result == nil; i++ { + if err := bot.PlayFullTurn(g); err != nil { + t.Fatalf("шаг %d: неожиданная ошибка: %v", i, err) + } + } +} diff --git a/chess_game.go b/chess_game.go new file mode 100644 index 0000000..06e808a --- /dev/null +++ b/chess_game.go @@ -0,0 +1,569 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: реализованы полные правила шахмат, включая +// рокировку (в обе стороны, со всеми условиями: ни король, ни ладья +// не должны были ходить, поля между ними свободны, король не под +// шахом и не проходит через атакованное поле) и взятие на проходе. +// Превращение пешки УПРОЩЕНО: пешка всегда превращается в ферзя +// (автоматически, без выбора фигуры) — это стандартное упрощение +// для большинства цифровых реализаций, так как подавляющее +// большинство реальных превращений и так выбирают ферзя. Из +// официальных правил ничьей реализованы мат/пат, недостаточность +// материала и упрощённый аналог правила 75 полуходов без взятия и +// хода пешкой (автоматическое завершение партии, без необходимости +// заявлять ничью); троекратное повторение позиции НЕ реализовано +// (потребовало бы хранить историю всех позиций). + +// ChessColor — цвет стороны. +type ChessColor int + +const ( + ChessWhite ChessColor = iota + ChessBlack +) + +func (c ChessColor) Opponent() ChessColor { + if c == ChessWhite { + return ChessBlack + } + return ChessWhite +} + +// ChessPieceType — тип фигуры. +type ChessPieceType int + +const ( + ChessPawn ChessPieceType = iota + ChessKnight + ChessBishop + ChessRook + ChessQueen + ChessKing +) + +// ChessPiece — фигура на доске. +type ChessPiece struct { + Color ChessColor + Type ChessPieceType +} + +// ChessPos — координаты клетки (0-7, 0-7). Row 0 = 8-я горизонталь +// (чёрные сзади), Row 7 = 1-я горизонталь (белые сзади). Col 0-7 = +// вертикали a-h. +type ChessPos struct { + Row, Col int +} + +func (p ChessPos) inBounds() bool { + return p.Row >= 0 && p.Row < 8 && p.Col >= 0 && p.Col < 8 +} + +var ( + ErrChessNoPiece = errors.New("на этой клетке нет вашей фигуры") + ErrChessInvalidMove = errors.New("такой ход недопустим") + ErrChessGameOver = errors.New("партия уже завершена") +) + +// chessMoveOption — один вариант хода для конкретной фигуры. +type chessMoveOption struct { + To ChessPos + IsEnPassant bool + IsCastleKingside bool + IsCastleQueenside bool + IsPromotion bool +} + +// ChessResult — итог завершившейся партии. +type ChessResult struct { + Winner ChessColor + Checkmate bool + Draw bool + DrawReason string // "stalemate", "insufficient_material", "no_progress" +} + +// maxChessHalfmovesWithoutProgress — защита от бесконечных партий +// без взятий и ходов пешками (упрощённый аналог официального +// правила 75 полуходов). +const maxChessHalfmovesWithoutProgress = 150 + +// ChessGameState — полное состояние партии в шахматы. +type ChessGameState struct { + Board [8][8]*ChessPiece + Turn ChessColor + + WhiteKingMoved bool + WhiteRookQueensideMoved bool // ладья a1 + WhiteRookKingsideMoved bool // ладья h1 + BlackKingMoved bool + BlackRookQueensideMoved bool // ладья a8 + BlackRookKingsideMoved bool // ладья h8 + + EnPassantTarget *ChessPos // клетка "за" пешкой, только что шагнувшей на 2 — цель взятия на проходе + HalfmoveClock int // полуходов без взятия/хода пешкой + + Result *ChessResult +} + +// NewChessGame создаёт новую партию со стандартной расстановкой; +// первый ход — за белыми. +func NewChessGame() *ChessGameState { + g := &ChessGameState{Turn: ChessWhite} + backRank := []ChessPieceType{ChessRook, ChessKnight, ChessBishop, ChessQueen, ChessKing, ChessBishop, ChessKnight, ChessRook} + for col, t := range backRank { + g.Board[0][col] = &ChessPiece{Color: ChessBlack, Type: t} + g.Board[7][col] = &ChessPiece{Color: ChessWhite, Type: t} + } + for col := 0; col < 8; col++ { + g.Board[1][col] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + g.Board[6][col] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + } + return g +} + +func (p ChessPos) add(dr, dc int) ChessPos { return ChessPos{Row: p.Row + dr, Col: p.Col + dc} } + +var chessBishopDirs = []ChessPos{{Row: -1, Col: -1}, {Row: -1, Col: 1}, {Row: 1, Col: -1}, {Row: 1, Col: 1}} +var chessRookDirs = []ChessPos{{Row: -1, Col: 0}, {Row: 1, Col: 0}, {Row: 0, Col: -1}, {Row: 0, Col: 1}} +var chessKnightOffsets = []ChessPos{{-2, -1}, {-2, 1}, {-1, -2}, {-1, 2}, {1, -2}, {1, 2}, {2, -1}, {2, 1}} + +// slidingMoves перечисляет ходы скользящей фигуры (ладья/слон/ферзь) +// вдоль набора направлений, останавливаясь на первой фигуре (со +// взятием, если это вражеская). +func (g *ChessGameState) slidingMoves(pos ChessPos, dirs []ChessPos, color ChessColor) []chessMoveOption { + var out []chessMoveOption + for _, d := range dirs { + for step := 1; ; step++ { + np := pos.add(d.Row*step, d.Col*step) + if !np.inBounds() { + break + } + occupant := g.Board[np.Row][np.Col] + if occupant == nil { + out = append(out, chessMoveOption{To: np}) + continue + } + if occupant.Color != color { + out = append(out, chessMoveOption{To: np}) + } + break + } + } + return out +} + +// pseudoLegalMoves перечисляет ходы фигуры без учёта того, остаётся +// ли после них свой король под шахом (эта проверка — уровнем выше, +// в LegalMovesFrom). +func (g *ChessGameState) pseudoLegalMoves(pos ChessPos) []chessMoveOption { + piece := g.Board[pos.Row][pos.Col] + if piece == nil { + return nil + } + switch piece.Type { + case ChessKnight: + var out []chessMoveOption + for _, o := range chessKnightOffsets { + np := pos.add(o.Row, o.Col) + if !np.inBounds() { + continue + } + occupant := g.Board[np.Row][np.Col] + if occupant == nil || occupant.Color != piece.Color { + out = append(out, chessMoveOption{To: np}) + } + } + return out + case ChessBishop: + return g.slidingMoves(pos, chessBishopDirs, piece.Color) + case ChessRook: + return g.slidingMoves(pos, chessRookDirs, piece.Color) + case ChessQueen: + out := g.slidingMoves(pos, chessBishopDirs, piece.Color) + return append(out, g.slidingMoves(pos, chessRookDirs, piece.Color)...) + case ChessKing: + return g.kingMoves(pos, piece.Color) + case ChessPawn: + return g.pawnMoves(pos, piece.Color) + } + return nil +} + +func (g *ChessGameState) kingMoves(pos ChessPos, color ChessColor) []chessMoveOption { + var out []chessMoveOption + for _, d := range append(append([]ChessPos{}, chessBishopDirs...), chessRookDirs...) { + np := pos.add(d.Row, d.Col) + if !np.inBounds() { + continue + } + occupant := g.Board[np.Row][np.Col] + if occupant == nil || occupant.Color != color { + out = append(out, chessMoveOption{To: np}) + } + } + out = append(out, g.castlingMoves(pos, color)...) + return out +} + +func (g *ChessGameState) castlingMoves(kingPos ChessPos, color ChessColor) []chessMoveOption { + if g.inCheck(color) { + return nil // нельзя рокироваться, находясь под шахом + } + row := kingPos.Row + var kingMoved, rookQMoved, rookKMoved bool + if color == ChessWhite { + kingMoved, rookQMoved, rookKMoved = g.WhiteKingMoved, g.WhiteRookQueensideMoved, g.WhiteRookKingsideMoved + } else { + kingMoved, rookQMoved, rookKMoved = g.BlackKingMoved, g.BlackRookQueensideMoved, g.BlackRookKingsideMoved + } + if kingMoved { + return nil + } + var out []chessMoveOption + enemy := color.Opponent() + + // короткая рокировка (kingside): ладья h, король e->g + if !rookKMoved && g.Board[row][5] == nil && g.Board[row][6] == nil { + if rook := g.Board[row][7]; rook != nil && rook.Type == ChessRook && rook.Color == color { + if !g.isSquareAttacked(ChessPos{row, 5}, enemy) && !g.isSquareAttacked(ChessPos{row, 6}, enemy) { + out = append(out, chessMoveOption{To: ChessPos{row, 6}, IsCastleKingside: true}) + } + } + } + // длинная рокировка (queenside): ладья a, король e->c + if !rookQMoved && g.Board[row][1] == nil && g.Board[row][2] == nil && g.Board[row][3] == nil { + if rook := g.Board[row][0]; rook != nil && rook.Type == ChessRook && rook.Color == color { + if !g.isSquareAttacked(ChessPos{row, 3}, enemy) && !g.isSquareAttacked(ChessPos{row, 2}, enemy) { + out = append(out, chessMoveOption{To: ChessPos{row, 2}, IsCastleQueenside: true}) + } + } + } + return out +} + +func (g *ChessGameState) pawnMoves(pos ChessPos, color ChessColor) []chessMoveOption { + var out []chessMoveOption + forward := 1 + startRow := 1 + lastRow := 7 + if color == ChessWhite { + forward = -1 + startRow = 6 + lastRow = 0 + } + + one := pos.add(forward, 0) + if one.inBounds() && g.Board[one.Row][one.Col] == nil { + out = append(out, chessMoveOption{To: one, IsPromotion: one.Row == lastRow}) + if pos.Row == startRow { + two := pos.add(forward*2, 0) + if g.Board[two.Row][two.Col] == nil { + out = append(out, chessMoveOption{To: two}) + } + } + } + for _, dc := range []int{-1, 1} { + capture := pos.add(forward, dc) + if !capture.inBounds() { + continue + } + if occupant := g.Board[capture.Row][capture.Col]; occupant != nil && occupant.Color != color { + out = append(out, chessMoveOption{To: capture, IsPromotion: capture.Row == lastRow}) + continue + } + if g.EnPassantTarget != nil && *g.EnPassantTarget == capture { + out = append(out, chessMoveOption{To: capture, IsEnPassant: true}) + } + } + return out +} + +// isSquareAttacked проверяет, атакована ли клетка pos какой-либо +// фигурой цвета byColor. +func (g *ChessGameState) isSquareAttacked(pos ChessPos, byColor ChessColor) bool { + dir := 1 + if byColor == ChessWhite { + dir = -1 + } + for _, dc := range []int{-1, 1} { + p := pos.add(-dir, dc) + if p.inBounds() { + if occ := g.Board[p.Row][p.Col]; occ != nil && occ.Color == byColor && occ.Type == ChessPawn { + return true + } + } + } + for _, o := range chessKnightOffsets { + p := pos.add(o.Row, o.Col) + if p.inBounds() { + if occ := g.Board[p.Row][p.Col]; occ != nil && occ.Color == byColor && occ.Type == ChessKnight { + return true + } + } + } + for _, d := range append(append([]ChessPos{}, chessBishopDirs...), chessRookDirs...) { + p := pos.add(d.Row, d.Col) + if p.inBounds() { + if occ := g.Board[p.Row][p.Col]; occ != nil && occ.Color == byColor && occ.Type == ChessKing { + return true + } + } + } + for _, d := range chessBishopDirs { + for step := 1; ; step++ { + p := pos.add(d.Row*step, d.Col*step) + if !p.inBounds() { + break + } + occ := g.Board[p.Row][p.Col] + if occ == nil { + continue + } + if occ.Color == byColor && (occ.Type == ChessBishop || occ.Type == ChessQueen) { + return true + } + break + } + } + for _, d := range chessRookDirs { + for step := 1; ; step++ { + p := pos.add(d.Row*step, d.Col*step) + if !p.inBounds() { + break + } + occ := g.Board[p.Row][p.Col] + if occ == nil { + continue + } + if occ.Color == byColor && (occ.Type == ChessRook || occ.Type == ChessQueen) { + return true + } + break + } + } + return false +} + +func (g *ChessGameState) findKing(color ChessColor) ChessPos { + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + if p := g.Board[r][c]; p != nil && p.Color == color && p.Type == ChessKing { + return ChessPos{r, c} + } + } + } + return ChessPos{-1, -1} // не должно происходить при корректном состоянии +} + +func (g *ChessGameState) inCheck(color ChessColor) bool { + return g.isSquareAttacked(g.findKing(color), color.Opponent()) +} + +// cloneChess создаёт независимую глубокую копию — нужна для +// проверки "не остаётся ли король под шахом после хода" и для +// минимакса бота. +func cloneChess(g *ChessGameState) *ChessGameState { + clone := *g + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + if p := g.Board[r][c]; p != nil { + cp := *p + clone.Board[r][c] = &cp + } else { + clone.Board[r][c] = nil + } + } + } + if g.EnPassantTarget != nil { + ep := *g.EnPassantTarget + clone.EnPassantTarget = &ep + } + if g.Result != nil { + res := *g.Result + clone.Result = &res + } + return &clone +} + +// LegalMovesFrom возвращает допустимые ходы фигуры в pos — +// псевдолегальные ходы, отфильтрованные так, чтобы после них +// собственный король не оставался под шахом. +func (g *ChessGameState) LegalMovesFrom(pos ChessPos) []chessMoveOption { + if g.Result != nil { + return nil + } + piece := g.Board[pos.Row][pos.Col] + if piece == nil || piece.Color != g.Turn { + return nil + } + var out []chessMoveOption + for _, opt := range g.pseudoLegalMoves(pos) { + clone := cloneChess(g) + clone.applyMove(pos, opt) + if !clone.inCheck(piece.Color) { + out = append(out, opt) + } + } + return out +} + +// applyMove выполняет механику хода (перемещение фигур, взятие на +// проходе, рокировка, превращение) БЕЗ проверки легальности — +// вызывающий код должен был её уже провести. +func (g *ChessGameState) applyMove(from ChessPos, opt chessMoveOption) { + piece := g.Board[from.Row][from.Col] + + if opt.IsEnPassant { + g.Board[from.Row][opt.To.Col] = nil // взятая пешка стоит на строке from, столбце назначения + } + if opt.IsCastleKingside { + rook := g.Board[from.Row][7] + g.Board[from.Row][7] = nil + g.Board[from.Row][5] = rook + } + if opt.IsCastleQueenside { + rook := g.Board[from.Row][0] + g.Board[from.Row][0] = nil + g.Board[from.Row][3] = rook + } + + g.Board[from.Row][from.Col] = nil + if opt.IsPromotion { + g.Board[opt.To.Row][opt.To.Col] = &ChessPiece{Color: piece.Color, Type: ChessQueen} + } else { + g.Board[opt.To.Row][opt.To.Col] = piece + } + + if piece.Type == ChessKing { + if piece.Color == ChessWhite { + g.WhiteKingMoved = true + } else { + g.BlackKingMoved = true + } + } + markRookMoved := func(pos ChessPos) { + if pos.Row == 7 && pos.Col == 0 { + g.WhiteRookQueensideMoved = true + } + if pos.Row == 7 && pos.Col == 7 { + g.WhiteRookKingsideMoved = true + } + if pos.Row == 0 && pos.Col == 0 { + g.BlackRookQueensideMoved = true + } + if pos.Row == 0 && pos.Col == 7 { + g.BlackRookKingsideMoved = true + } + } + markRookMoved(from) + markRookMoved(opt.To) // на случай взятия ладьи соперника + + if piece.Type == ChessPawn && abs(opt.To.Row-from.Row) == 2 { + mid := ChessPos{Row: (from.Row + opt.To.Row) / 2, Col: from.Col} + g.EnPassantTarget = &mid + } else { + g.EnPassantTarget = nil + } +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// hasAnyLegalMove проверяет, есть ли у стороны color хоть один +// легальный ход где-либо на доске. +func (g *ChessGameState) hasAnyLegalMove(color ChessColor) bool { + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil || p.Color != color { + continue + } + if len(g.LegalMovesFrom(ChessPos{r, c})) > 0 { + return true + } + } + } + return false +} + +// hasInsufficientMaterial проверяет самые распространённые +// теоретически недостаточные для мата комбинации: King vs King, +// King+Knight vs King, King+Bishop vs King. Более редкие ничейные +// комбинации (например, слоны на клетках одного цвета с обеих +// сторон) не проверяются — упрощение. +func (g *ChessGameState) hasInsufficientMaterial() bool { + var minor []ChessPieceType + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil || p.Type == ChessKing { + continue + } + if p.Type != ChessKnight && p.Type != ChessBishop { + return false + } + minor = append(minor, p.Type) + } + } + return len(minor) <= 1 +} + +// Move выполняет ход фигурой из from в to, если он допустим по +// текущим правилам (см. LegalMovesFrom). Превращение пешки всегда +// автоматически происходит в ферзя. +func (g *ChessGameState) Move(from, to ChessPos) error { + if g.Result != nil { + return ErrChessGameOver + } + piece := g.Board[from.Row][from.Col] + if piece == nil || piece.Color != g.Turn { + return ErrChessNoPiece + } + options := g.LegalMovesFrom(from) + var chosen *chessMoveOption + for i := range options { + if options[i].To == to { + chosen = &options[i] + break + } + } + if chosen == nil { + return ErrChessInvalidMove + } + + isCaptureOrPawn := piece.Type == ChessPawn || g.Board[to.Row][to.Col] != nil || chosen.IsEnPassant + g.applyMove(from, *chosen) + + if isCaptureOrPawn { + g.HalfmoveClock = 0 + } else { + g.HalfmoveClock++ + } + + g.Turn = g.Turn.Opponent() + g.checkGameOver() + return nil +} + +func (g *ChessGameState) checkGameOver() { + if g.hasInsufficientMaterial() { + g.Result = &ChessResult{Draw: true, DrawReason: "insufficient_material"} + return + } + if g.HalfmoveClock >= maxChessHalfmovesWithoutProgress { + g.Result = &ChessResult{Draw: true, DrawReason: "no_progress"} + return + } + if !g.hasAnyLegalMove(g.Turn) { + if g.inCheck(g.Turn) { + g.Result = &ChessResult{Winner: g.Turn.Opponent(), Checkmate: true} + } else { + g.Result = &ChessResult{Draw: true, DrawReason: "stalemate"} + } + } +} diff --git a/chess_game_test.go b/chess_game_test.go new file mode 100644 index 0000000..99636ec --- /dev/null +++ b/chess_game_test.go @@ -0,0 +1,368 @@ +package main + +import "testing" + +func cp(r, c int) ChessPos { return ChessPos{Row: r, Col: c} } + +func TestNewChessGameSetup(t *testing.T) { + g := NewChessGame() + whiteCount, blackCount := 0, 0 + for r := 0; r < 8; r++ { + for c := 0; c < 8; c++ { + p := g.Board[r][c] + if p == nil { + continue + } + if p.Color == ChessWhite { + whiteCount++ + } else { + blackCount++ + } + } + } + if whiteCount != 16 || blackCount != 16 { + t.Errorf("ожидалось по 16 фигур, получено white=%d black=%d", whiteCount, blackCount) + } + if g.Turn != ChessWhite { + t.Errorf("первый ход должен быть за белыми") + } + if g.Board[7][4].Type != ChessKing || g.Board[7][3].Type != ChessQueen { + t.Errorf("король и ферзь белых должны стоять на своих клетках") + } +} + +func TestPawnDoubleStepAndSingleStep(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[6][4] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + + if err := g.Move(cp(6, 4), cp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка хода пешкой на 2: %v", err) + } + if g.EnPassantTarget == nil || *g.EnPassantTarget != cp(5, 4) { + t.Errorf("ожидалась цель взятия на проходе (5,4)") + } +} + +func TestPawnCannotJumpOverPiece(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[6][4] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[5][4] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + + if err := g.Move(cp(6, 4), cp(4, 4)); err != ErrChessInvalidMove { + t.Errorf("пешка не должна перепрыгивать через фигуру, получено: %v", err) + } +} + +func TestPawnCapturesDiagonally(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[6][4] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[5][5] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + + if err := g.Move(cp(6, 4), cp(5, 5)); err != nil { + t.Fatalf("неожиданная ошибка взятия по диагонали: %v", err) + } + if g.Board[5][5].Color != ChessWhite { + t.Errorf("белая пешка должна была оказаться на (5,5)") + } +} + +func TestEnPassantCapture(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[3][4] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[1][3] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + + g.Turn = ChessBlack + if err := g.Move(cp(1, 3), cp(3, 3)); err != nil { + t.Fatalf("неожиданная ошибка хода чёрной пешкой на 2: %v", err) + } + if g.EnPassantTarget == nil || *g.EnPassantTarget != cp(2, 3) { + t.Fatalf("ожидалась цель взятия на проходе (2,3)") + } + + if err := g.Move(cp(3, 4), cp(2, 3)); err != nil { + t.Fatalf("неожиданная ошибка взятия на проходе: %v", err) + } + if g.Board[3][3] != nil { + t.Errorf("взятая на проходе пешка должна была исчезнуть с (3,3)") + } + if g.Board[2][3] == nil || g.Board[2][3].Color != ChessWhite { + t.Errorf("белая пешка должна была оказаться на (2,3)") + } +} + +func TestEnPassantOnlyAvailableImmediately(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[3][4] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[1][3] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + g.Board[6][0] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[1][0] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + + g.Turn = ChessBlack + if err := g.Move(cp(1, 3), cp(3, 3)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Move(cp(6, 0), cp(5, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if err := g.Move(cp(1, 0), cp(2, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + opts := g.LegalMovesFrom(cp(3, 4)) + for _, o := range opts { + if o.IsEnPassant { + t.Errorf("право на взятие на проходе должно было сгореть") + } + } +} + +func TestPromotionToQueen(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[1][0] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + g.Board[0][7] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + + if err := g.Move(cp(1, 0), cp(0, 0)); err != nil { + t.Fatalf("неожиданная ошибка превращения: %v", err) + } + if g.Board[0][0] == nil || g.Board[0][0].Type != ChessQueen { + t.Errorf("пешка должна была превратиться в ферзя") + } +} + +func TestKnightMovesInLShape(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[4][4] = &ChessPiece{Color: ChessWhite, Type: ChessKnight} + opts := g.LegalMovesFrom(cp(4, 4)) + want := map[ChessPos]bool{ + cp(2, 3): true, cp(2, 5): true, cp(3, 2): true, cp(3, 6): true, + cp(5, 2): true, cp(5, 6): true, cp(6, 3): true, cp(6, 5): true, + } + if len(opts) != 8 { + t.Fatalf("ожидалось 8 ходов коня из центра доски, получено %d", len(opts)) + } + for _, o := range opts { + if !want[o.To] { + t.Errorf("неожиданный ход коня на %v", o.To) + } + } +} + +func TestBishopBlockedByOwnPiece(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[4][4] = &ChessPiece{Color: ChessWhite, Type: ChessBishop} + g.Board[2][2] = &ChessPiece{Color: ChessWhite, Type: ChessPawn} + opts := g.LegalMovesFrom(cp(4, 4)) + for _, o := range opts { + if o.To == cp(2, 2) || o.To == cp(1, 1) { + t.Errorf("слон не должен проходить через свою же фигуру или вставать на неё") + } + } +} + +func TestRookCapturesEnemyButStops(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[4][4] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[4][6] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + opts := g.LegalMovesFrom(cp(4, 4)) + foundCapture, foundBeyond := false, false + for _, o := range opts { + if o.To == cp(4, 6) { + foundCapture = true + } + if o.To == cp(4, 7) { + foundBeyond = true + } + } + if !foundCapture { + t.Errorf("ладья должна была иметь возможность взять вражескую пешку") + } + if foundBeyond { + t.Errorf("ладья не должна проходить сквозь взятую фигуру") + } +} + +func TestCastlingKingsideWhenClear(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + + if err := g.Move(cp(7, 4), cp(7, 6)); err != nil { + t.Fatalf("неожиданная ошибка короткой рокировки: %v", err) + } + if g.Board[7][6] == nil || g.Board[7][6].Type != ChessKing { + t.Errorf("король должен был оказаться на g1") + } + if g.Board[7][5] == nil || g.Board[7][5].Type != ChessRook { + t.Errorf("ладья должна была оказаться на f1") + } + if g.Board[7][7] != nil { + t.Errorf("исходная клетка ладьи должна была опустеть") + } +} + +func TestCastlingQueensideWhenClear(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][0] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + + if err := g.Move(cp(7, 4), cp(7, 2)); err != nil { + t.Fatalf("неожиданная ошибка длинной рокировки: %v", err) + } + if g.Board[7][2] == nil || g.Board[7][2].Type != ChessKing { + t.Errorf("король должен был оказаться на c1") + } + if g.Board[7][3] == nil || g.Board[7][3].Type != ChessRook { + t.Errorf("ладья должна была оказаться на d1") + } +} + +func TestCastlingForbiddenAfterKingMoved(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.WhiteKingMoved = true + + opts := g.LegalMovesFrom(cp(7, 4)) + for _, o := range opts { + if o.IsCastleKingside { + t.Errorf("рокировка должна быть запрещена, если король уже ходил") + } + } +} + +func TestCastlingForbiddenWhileInCheck(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessRook} // держит короля под шахом по вертикали e + + opts := g.LegalMovesFrom(cp(7, 4)) + for _, o := range opts { + if o.IsCastleKingside || o.IsCastleQueenside { + t.Errorf("рокировка должна быть запрещена под шахом") + } + } +} + +func TestCastlingForbiddenThroughAttackedSquare(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][5] = &ChessPiece{Color: ChessBlack, Type: ChessRook} // бьёт f1 (клетка, через которую проходит король) + + opts := g.LegalMovesFrom(cp(7, 4)) + for _, o := range opts { + if o.IsCastleKingside { + t.Errorf("рокировка должна быть запрещена, если король проходит через атакованную клетку") + } + } +} + +func TestCannotMoveIntoCheck(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[0][3] = &ChessPiece{Color: ChessBlack, Type: ChessRook} + + if err := g.Move(cp(7, 4), cp(7, 3)); err != ErrChessInvalidMove { + t.Errorf("нельзя вставать королём под шах, ожидалась ошибка, получено: %v", err) + } +} + +func TestPinnedPieceCannotMove(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[6][4] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessRook} + + opts := g.LegalMovesFrom(cp(6, 4)) + for _, o := range opts { + if o.To.Col != 4 { + t.Errorf("связанная ладья не должна иметь возможность сойти с вертикали e, ход на %v", o.To) + } + } +} + +func TestFoolsMateCheckmate(t *testing.T) { + g := NewChessGame() + moves := []struct{ from, to ChessPos }{ + {cp(6, 5), cp(5, 5)}, // f2-f3 + {cp(1, 4), cp(3, 4)}, // e7-e5 + {cp(6, 6), cp(4, 6)}, // g2-g4 + {cp(0, 3), cp(4, 7)}, // Qd8-h4# + } + for i, mv := range moves { + if err := g.Move(mv.from, mv.to); err != nil { + t.Fatalf("ход %d неожиданно не прошёл: %v", i, err) + } + } + if g.Result == nil || !g.Result.Checkmate { + t.Fatalf("ожидался мат ('детский мат'), получено: %+v", g.Result) + } + if g.Result.Winner != ChessBlack { + t.Errorf("ожидался победитель — чёрные") + } +} + +func TestStalemateDraw(t *testing.T) { + g := &ChessGameState{Turn: ChessBlack} + g.Board[0][7] = &ChessPiece{Color: ChessBlack, Type: ChessKing} // h8 + g.Board[2][6] = &ChessPiece{Color: ChessWhite, Type: ChessQueen} // g6 + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} // e1, далеко и не мешает + + if g.inCheck(ChessBlack) { + t.Fatalf("в этой тестовой позиции чёрный король не должен быть под шахом") + } + if g.hasAnyLegalMove(ChessBlack) { + t.Fatalf("в этой тестовой позиции у чёрных не должно быть легальных ходов") + } + g.checkGameOver() + if g.Result == nil || !g.Result.Draw || g.Result.DrawReason != "stalemate" { + t.Fatalf("ожидался пат, получено: %+v", g.Result) + } +} + +func TestInsufficientMaterialKingVsKing(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[0][0] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + if !g.hasInsufficientMaterial() { + t.Errorf("король против короля — недостаточно материала для мата") + } +} + +func TestSufficientMaterialWithRook(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[0][0] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[4][4] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + if g.hasInsufficientMaterial() { + t.Errorf("с ладьёй на доске материала достаточно для мата") + } +} + +func TestChessCannotMoveOpponentPiece(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite} + g.Board[1][4] = &ChessPiece{Color: ChessBlack, Type: ChessPawn} + + if err := g.Move(cp(1, 4), cp(2, 4)); err != ErrChessNoPiece { + t.Errorf("ожидалась ошибка хода чужой фигурой, получено: %v", err) + } +} + +func TestNoProgressDrawLimit(t *testing.T) { + g := &ChessGameState{Turn: ChessWhite, HalfmoveClock: maxChessHalfmovesWithoutProgress - 1} + g.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + g.Board[7][0] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + g.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + g.Board[0][7] = &ChessPiece{Color: ChessBlack, Type: ChessRook} + + if err := g.Move(cp(7, 0), cp(6, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil || !g.Result.Draw || g.Result.DrawReason != "no_progress" { + t.Fatalf("ожидалась ничья по правилу отсутствия прогресса, получено: %+v", g.Result) + } +} diff --git a/chess_tui.go b/chess_tui.go new file mode 100644 index 0000000..22ae133 --- /dev/null +++ b/chess_tui.go @@ -0,0 +1,322 @@ +package main + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ChessModel — TUI поверх ChessGameState. Человек всегда играет +// белыми (ходят первыми), бот — чёрными. Превращение пешки +// автоматическое (всегда в ферзя, см. примечание в chess_game.go), +// поэтому отдельного шага выбора фигуры превращения не требуется. +type ChessModel struct { + game *ChessGameState + bot ChessBot + human ChessColor + cursor ChessPos + selected *ChessPos + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewChessModel создаёт новую партию: человек играет белыми. depth — +// глубина поиска бота в полуходах (0 -> значение по умолчанию). +func NewChessModel(depth int) ChessModel { + return ChessModel{ + game: NewChessGame(), + bot: ChessBot{Depth: depth}, + human: ChessWhite, + cursor: ChessPos{Row: 7, Col: 4}, + } +} + +func (m ChessModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +func (m ChessModel) maybeScheduleBot() tea.Cmd { + if m.game.Result != nil { + return nil + } + if m.game.Turn == m.human { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *ChessModel) setInfo(format string, args ...interface{}) { + m.message = fmt.Sprintf(format, args...) + m.isError = false +} + +func (m *ChessModel) setError(err error) { + m.message = T(err.Error()) + m.isError = true +} + +func (m ChessModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + case botMoveMsg: + return m.handleBotMove() + } + return m, nil +} + +func (m ChessModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Result != nil || m.game.Turn == m.human { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game); err != nil { + m.setError(err) + return m, nil + } + m.setInfo(T("chess.msg.bot_moved")) + return m, m.maybeScheduleBot() +} + +func (m ChessModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + if key == "q" { + m.backToMenu = true + return m, nil + } + + if m.game.Result != nil { + if key == "n" { + m.game = NewChessGame() + m.selected = nil + m.message = "" + m.isError = false + return m, m.maybeScheduleBot() + } + return m, nil + } + + if m.game.Turn != m.human { + return m, nil + } + + switch key { + case "up", "k": + if m.cursor.Row > 0 { + m.cursor.Row-- + } + case "down", "j": + if m.cursor.Row < 7 { + m.cursor.Row++ + } + case "left", "h": + if m.cursor.Col > 0 { + m.cursor.Col-- + } + case "right", "l": + if m.cursor.Col < 7 { + m.cursor.Col++ + } + case "esc": + m.selected = nil + m.message = "" + case "enter", " ": + return m.handleSelectOrMove() + } + return m, nil +} + +func (m ChessModel) handleSelectOrMove() (tea.Model, tea.Cmd) { + if m.selected == nil { + piece := m.game.Board[m.cursor.Row][m.cursor.Col] + if piece == nil || piece.Color != m.human { + m.setError(ErrChessNoPiece) + return m, nil + } + if len(m.game.LegalMovesFrom(m.cursor)) == 0 { + m.setError(ErrChessInvalidMove) + return m, nil + } + pos := m.cursor + m.selected = &pos + m.message = "" + return m, nil + } + + if *m.selected == m.cursor { + m.selected = nil + return m, nil + } + + from := *m.selected + err := m.game.Move(from, m.cursor) + if err != nil { + m.setError(err) + return m, nil + } + m.setInfo(T("chess.msg.moved")) + m.selected = nil + return m, m.maybeScheduleBot() +} + +// --- Отрисовка ----------------------------------------------------- + +// chessGlyphs — буквы фигур по стандартной шахматной нотации: +// заглавные для белых, строчные для чёрных (цвет дополнительно +// подчёркивается стилем текста). Сознательно НЕ используются +// символы Unicode ♔♕♖♗♘♙ — у них неоднозначная ширина отображения +// в разных шрифтах/рендерерах (где-то один символ на ширину клетки, +// где-то два), из-за чего клетки с фигурами визуально "сжимались" +// относительно пустых клеток. Буквы ASCII гарантированно имеют +// одинаковую ширину везде. +var chessGlyphs = map[ChessPieceType][2]string{ + ChessKing: {"K", "k"}, + ChessQueen: {"Q", "q"}, + ChessRook: {"R", "r"}, + ChessBishop: {"B", "b"}, + ChessKnight: {"N", "n"}, + ChessPawn: {"P", "p"}, +} + +// chessGlyphChar возвращает "сырую" (без цвета) букву фигуры — +// используется вместе с chessPieceForegroundColor, чтобы построить +// один комбинированный стиль (фон клетки + цвет фигуры) вместо +// вложенных вызовов Render(), которые ломаются в некоторых +// терминалах. +func chessGlyphChar(p *ChessPiece) string { + if p == nil { + return " " + } + pair := chessGlyphs[p.Type] + if p.Color == ChessBlack { + return pair[1] + } + return pair[0] +} + +// chessPieceForegroundColor возвращает цвет буквы фигуры (второе +// значение — false, если клетка пуста). +func chessPieceForegroundColor(p *ChessPiece) (lipgloss.Color, bool) { + if p == nil { + return "", false + } + if p.Color == ChessWhite { + return checkersWhitePieceColor, true + } + return checkersBlackPieceColor, true +} + +func (m ChessModel) legalDestinationSet() map[ChessPos]bool { + set := map[ChessPos]bool{} + if m.selected == nil { + return set + } + for _, opt := range m.game.LegalMovesFrom(*m.selected) { + set[opt.To] = true + } + return set +} + +func (m ChessModel) renderBoard() string { + dests := m.legalDestinationSet() + var b strings.Builder + fmt.Fprintln(&b, " a b c d e f g h") + for r := 0; r < 8; r++ { + fmt.Fprintf(&b, "%d ", 8-r) + for c := 0; c < 8; c++ { + pos := ChessPos{Row: r, Col: c} + cellStyle := checkersLightSquare + if (r+c)%2 == 1 { + cellStyle = checkersDarkSquare + } + + switch { + case m.cursor == pos: + cellStyle = checkersCursorStyle + case m.selected != nil && *m.selected == pos: + cellStyle = checkersSelStyle + case dests[pos]: + cellStyle = checkersSelStyle + } + + piece := m.game.Board[r][c] + text := chessGlyphChar(piece) + combined := cellStyle + if fg, ok := chessPieceForegroundColor(piece); ok { + combined = cellStyle.Foreground(fg).Bold(true) + } + fmt.Fprint(&b, combined.Render(" "+text+" ")) + } + fmt.Fprintln(&b) + } + return b.String() +} + +func (m ChessModel) View() string { + if m.quitting { + return T("common.goodbye") + } + + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("chess.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderBoard()) + fmt.Fprintln(&b, dimStyle.Render(T("chess.legend"))) + fmt.Fprintln(&b) + + if m.game.Result != nil { + res := m.game.Result + switch { + case res.Checkmate && res.Winner == m.human: + fmt.Fprintln(&b, winStyle.Render(T("chess.result.win_checkmate"))) + case res.Checkmate: + fmt.Fprintln(&b, errorStyle.Render(T("chess.result.lose_checkmate"))) + case res.Draw: + fmt.Fprintln(&b, errorStyle.Render(Tf("chess.result.draw", T("chess.drawreason."+res.DrawReason)))) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render(T("chess.hint.gameover"))) + return b.String() + } + + if m.game.inCheck(m.game.Turn) { + fmt.Fprintln(&b, errorStyle.Render(T("chess.check"))) + } + + turnLabel := T("chess.turn.you") + if m.game.Turn != m.human { + turnLabel = T("chess.turn.bot") + } + fmt.Fprintln(&b, turnLabel) + fmt.Fprintln(&b) + + if m.game.Turn == m.human { + if m.selected == nil { + fmt.Fprintln(&b, T("chess.hint.noselect")) + } else { + fmt.Fprintln(&b, T("chess.hint.selected")) + } + } + + fmt.Fprintln(&b) + if m.message != "" { + if m.isError { + fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) + } else { + fmt.Fprintln(&b, infoStyle.Render(m.message)) + } + } + + return b.String() +} diff --git a/chess_tui_test.go b/chess_tui_test.go new file mode 100644 index 0000000..2adeaca --- /dev/null +++ b/chess_tui_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func TestChessModelSelectAndMoveViaKeys(t *testing.T) { + m := NewChessModel(0) + m.cursor = ChessPos{Row: 6, Col: 4} + + next, _ := m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора: %s", m.message) + } + if m.selected == nil { + t.Fatalf("ожидался выбор фигуры") + } + + m.cursor = ChessPos{Row: 4, Col: 4} + next, _ = m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода: %s", m.message) + } + if m.game.Board[4][4] == nil { + t.Fatalf("пешка должна была переместиться на e4") + } + if m.selected != nil { + t.Errorf("выбор должен был сброситься после хода") + } +} + +func TestChessModelCastlingViaKeys(t *testing.T) { + m := NewChessModel(0) + m.game = &ChessGameState{Turn: ChessWhite} + m.game.Board[7][4] = &ChessPiece{Color: ChessWhite, Type: ChessKing} + m.game.Board[7][7] = &ChessPiece{Color: ChessWhite, Type: ChessRook} + m.game.Board[0][4] = &ChessPiece{Color: ChessBlack, Type: ChessKing} + m.cursor = ChessPos{Row: 7, Col: 4} + + next, _ := m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора короля: %s", m.message) + } + + m.cursor = ChessPos{Row: 7, Col: 6} + next, _ = m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка рокировки: %s", m.message) + } + if m.game.Board[7][6] == nil || m.game.Board[7][6].Type != ChessKing { + t.Errorf("король должен был оказаться на g1") + } + if m.game.Board[7][5] == nil || m.game.Board[7][5].Type != ChessRook { + t.Errorf("ладья должна была оказаться на f1") + } +} + +// TestChessModelCursorVisibleOnLegalDestination проверяет исправление +// бага: раньше подсветка "легальный ход" рендерилась поверх курсора, +// делая его невидимым, стоило навести курсор на клетку, куда можно +// сходить (именно эта клетка обычно и нужна пользователю). +// TestChessCursorOnPieceUsesOneCombinedStyle — тот же баг и та же +// проверка, что и в Шашках: цвет фигуры и фон клетки должны +// составлять один комбинированный стиль, а не вложенные Render(). +func TestChessCursorOnPieceUsesOneCombinedStyle(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + piece := &ChessPiece{Color: ChessBlack, Type: ChessKnight} + text := chessGlyphChar(piece) + fg, ok := chessPieceForegroundColor(piece) + if !ok { + t.Fatalf("ожидался цвет для непустой клетки") + } + combined := checkersCursorStyle.Foreground(fg).Bold(true) + rendered := combined.Render(" " + text + " ") + + resets := strings.Count(rendered, "\x1b[0m") + if resets != 1 { + t.Errorf("ожидался ровно один сброс ANSI на всю клетку (единый стиль), получено %d: %q", resets, rendered) + } + opens := strings.Count(rendered, "\x1b[") + if opens != 2 { + t.Errorf("ожидалось ровно 2 вхождения ESC (открытие+закрытие, без вложенности), получено %d: %q", opens, rendered) + } +} + +func TestChessModelCursorVisibleOnLegalDestination(t *testing.T) { + oldProfile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(oldProfile) + + m := NewChessModel(0) + m.cursor = ChessPos{Row: 6, Col: 4} + next, _ := m.Update(key("enter")) // выбираем пешку e2 + m = next.(ChessModel) + m.cursor = ChessPos{Row: 4, Col: 4} // e4 — легальный ход этой пешки + + dests := m.legalDestinationSet() + if !dests[m.cursor] { + t.Fatalf("тест предполагает, что курсор наведён именно на легальный ход") + } + + board := m.renderBoard() + cursorTag := checkersCursorStyle.Render("X") + // вытаскиваем именно код фона курсора из отрендеренной "X" + start := strings.Index(cursorTag, "\x1b[") + end := strings.Index(cursorTag, "m") + cursorColorCode := cursorTag[start : end+1] + if !strings.Contains(board, cursorColorCode) { + t.Errorf("цвет курсора должен присутствовать в отрисовке доски даже на клетке легального хода") + } +} + +func TestChessModelCursorMovement(t *testing.T) { + m := NewChessModel(0) + m.cursor = ChessPos{Row: 4, Col: 4} + + next, _ := m.Update(key("up")) + m = next.(ChessModel) + if m.cursor.Row != 3 { + t.Errorf("курсор должен был сдвинуться вверх") + } + next, _ = m.Update(key("right")) + m = next.(ChessModel) + if m.cursor.Col != 5 { + t.Errorf("курсор должен был сдвинуться вправо") + } +} + +func TestChessModelEscClearsSelection(t *testing.T) { + m := NewChessModel(0) + m.cursor = ChessPos{Row: 6, Col: 4} + next, _ := m.Update(key("enter")) + m = next.(ChessModel) + if m.selected == nil { + t.Fatalf("ожидался выбор") + } + + next, _ = m.Update(key("esc")) + m = next.(ChessModel) + if m.selected != nil { + t.Errorf("esc должен был сбросить выбор") + } +} + +func TestChessModelCannotSelectEmptySquare(t *testing.T) { + m := NewChessModel(0) + m.cursor = ChessPos{Row: 4, Col: 4} + + next, _ := m.Update(key("enter")) + m = next.(ChessModel) + if !m.isError { + t.Fatalf("ожидалась ошибка выбора пустой клетки") + } +} + +func TestChessModelQReturnsToMenu(t *testing.T) { + m := NewChessModel(0) + next, _ := m.Update(key("q")) + m = next.(ChessModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestChessModelNewGameAfterResult(t *testing.T) { + m := NewChessModel(0) + m.game.Result = &ChessResult{Winner: ChessWhite, Checkmate: true} + + next, _ := m.Update(key("n")) + m = next.(ChessModel) + if m.game.Result != nil { + t.Errorf("новая партия не должна была начаться с результатом") + } +} + +// TestChessModelFullAutoPlaythrough прогоняет TUI-модель от начала +// до конца, где "человек" отыгрывает свои ходы через бота +// (небольшая глубина — ради скорости), проверяя, что весь путь +// через Update не паникует и партия завершается. +func TestChessModelFullAutoPlaythrough(t *testing.T) { + m := NewChessModel(1) + helperBot := ChessBot{Depth: 1} + + const maxSteps = 600 + for i := 0; i < maxSteps && m.game.Result == nil; i++ { + if m.game.Turn != m.human { + next, _ := m.Update(botMoveMsg{}) + m = next.(ChessModel) + continue + } + mv, ok := helperBot.DecideMove(m.game) + if !ok { + break + } + m.cursor = mv.From + next, _ := m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка выбора хода человеком: %s", m.message) + } + m.cursor = mv.To + next, _ = m.Update(key("enter")) + m = next.(ChessModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода человеком: %s", m.message) + } + } + if m.game.Result == nil { + t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) + } +} diff --git a/corpsestarch_game.go b/corpsestarch_game.go new file mode 100644 index 0000000..59d8059 --- /dev/null +++ b/corpsestarch_game.go @@ -0,0 +1,312 @@ +package main + +import "time" + +// ПРИМЕЧАНИЕ: инкрементальная (idle/clicker) игра в духе Candy Box 2 +// — с постепенным открытием механик по мере накопления ресурса. +// Антураж — пародийная стилизация под сеттинг мрачного далёкого +// будущего (сервиторы, Муниторум, Скверна, зачистки), без +// копирования конкретных защищённых текстов, персонажей или +// фракций — только тематическая лексика. +// +// Прогресс существует только в рамках текущего запуска приложения +// (как и во всех остальных играх коллекции) — между перезапусками +// ничего не сохраняется. + +// corpseStarchServitorDef — статическое описание одного тира +// генератора пассивного дохода. +type corpseStarchServitorDef struct { + NameKey string + UnlockAt float64 // порог по TotalEarned, при котором тир становится виден + BaseCost float64 + CostScale float64 + YieldPerSec float64 +} + +var corpseStarchServitorDefs = []corpseStarchServitorDef{ + {NameKey: "corpsestarch.servitor.skull", UnlockAt: 0, BaseCost: 10, CostScale: 1.15, YieldPerSec: 0.5}, + {NameKey: "corpsestarch.servitor.menial", UnlockAt: 200, BaseCost: 100, CostScale: 1.15, YieldPerSec: 3}, + {NameKey: "corpsestarch.servitor.cogitator", UnlockAt: 2000, BaseCost: 1000, CostScale: 1.15, YieldPerSec: 20}, +} + +// corpseStarchMissionDef — статическое описание одной доступной +// миссии зачистки. +type corpseStarchMissionDef struct { + NameKey string + UnlockAt float64 + Duration time.Duration + RewardMin float64 + RewardMax float64 +} + +var corpseStarchMissionDefs = []corpseStarchMissionDef{ + {NameKey: "corpsestarch.mission.vermin", UnlockAt: 800, Duration: 20 * time.Second, RewardMin: 30, RewardMax: 60}, + {NameKey: "corpsestarch.mission.chapel", UnlockAt: 800, Duration: 60 * time.Second, RewardMin: 150, RewardMax: 250}, + {NameKey: "corpsestarch.mission.waaagh", UnlockAt: 800, Duration: 150 * time.Second, RewardMin: 500, RewardMax: 800}, +} + +// Пороги открытия основных разделов интерфейса (по TotalEarned). +const ( + corpseStarchUnlockServitors = 20 + corpseStarchUnlockShop = 80 + corpseStarchUnlockSump = 300 + corpseStarchUnlockMissions = 800 +) + +const ( + corpseStarchClickUpgradeBaseCost = 25 + corpseStarchClickUpgradeCostScale = 1.6 + corpseStarchClickUpgradeGain = 1 +) + +// corpseStarchMission — активная (запущенная) миссия. +type corpseStarchMission struct { + DefIdx int + StartedAt time.Time +} + +// CorpseStarchGameState — полное состояние партии. +type CorpseStarchGameState struct { + Starch float64 // текущий (тратимый) запас + TotalEarned float64 // накоплено за всю партию — используется для порогов открытия разделов + + ClickYield float64 + ClickUpgrades int + + ServitorCounts []int // по одному счётчику на каждый элемент corpseStarchServitorDefs + + Corruption int + + ActiveMission *corpseStarchMission + MissionsDone int + + // BonusMultiplier / BonusUntil — временный множитель к клику от + // удачного окунания цепного меча в отстойник. + BonusMultiplier float64 + BonusUntil time.Time + + LastTick time.Time + + Log []string // последние несколько строк лога событий, новые в конце +} + +const corpseStarchLogLimit = 6 + +// NewCorpseStarchGame создаёт новую партию. +func NewCorpseStarchGame(now time.Time) *CorpseStarchGameState { + return &CorpseStarchGameState{ + ClickYield: 1, + BonusMultiplier: 1, + ServitorCounts: make([]int, len(corpseStarchServitorDefs)), + LastTick: now, + } +} + +func (g *CorpseStarchGameState) addLog(key string) { + g.Log = append(g.Log, key) + if len(g.Log) > corpseStarchLogLimit { + g.Log = g.Log[len(g.Log)-corpseStarchLogLimit:] + } +} + +func (g *CorpseStarchGameState) earn(amount float64) { + if amount <= 0 { + return + } + g.Starch += amount + g.TotalEarned += amount +} + +// currentClickYield учитывает временный бонусный множитель, если он +// ещё действует на момент now. +func (g *CorpseStarchGameState) currentClickYield(now time.Time) float64 { + if now.Before(g.BonusUntil) { + return g.ClickYield * g.BonusMultiplier + } + return g.ClickYield +} + +// Requisition — ручное действие ("нажать кнопку"): начисляет один +// клик дохода. +func (g *CorpseStarchGameState) Requisition(now time.Time) { + g.earn(g.currentClickYield(now)) +} + +// Tick начисляет пассивный доход от сервиторов за время, прошедшее +// с последнего тика (now должен монотонно не убывать; для тестов +// можно подавать произвольные значения). Также сбрасывает истёкший +// временный бонус. +func (g *CorpseStarchGameState) Tick(now time.Time) { + elapsed := now.Sub(g.LastTick).Seconds() + g.LastTick = now + if elapsed <= 0 { + return + } + var perSec float64 + for i, def := range corpseStarchServitorDefs { + perSec += float64(g.ServitorCounts[i]) * def.YieldPerSec + } + g.earn(perSec * elapsed) +} + +// servitorCost возвращает стоимость следующей покупки тира idx. +func (g *CorpseStarchGameState) servitorCost(idx int) float64 { + def := corpseStarchServitorDefs[idx] + cost := def.BaseCost + for i := 0; i < g.ServitorCounts[idx]; i++ { + cost *= def.CostScale + } + return cost +} + +// BuyServitor покупает одну единицу тира idx, если хватает +// трупного крахмала и тир уже открыт по порогу. +func (g *CorpseStarchGameState) BuyServitor(idx int) bool { + if idx < 0 || idx >= len(corpseStarchServitorDefs) { + return false + } + if g.TotalEarned < corpseStarchServitorDefs[idx].UnlockAt { + return false + } + cost := g.servitorCost(idx) + if g.Starch < cost { + return false + } + g.Starch -= cost + g.ServitorCounts[idx]++ + return true +} + +func (g *CorpseStarchGameState) clickUpgradeCost() float64 { + cost := float64(corpseStarchClickUpgradeBaseCost) + for i := 0; i < g.ClickUpgrades; i++ { + cost *= corpseStarchClickUpgradeCostScale + } + return cost +} + +// BuyClickUpgrade повышает базовый доход от Реквизиции ("заточка +// цепного меча"). +func (g *CorpseStarchGameState) BuyClickUpgrade() bool { + cost := g.clickUpgradeCost() + if g.Starch < cost { + return false + } + g.Starch -= cost + g.ClickUpgrades++ + g.ClickYield += corpseStarchClickUpgradeGain + return true +} + +// CorpseStarchDipOutcome — исход окунания цепного меча в отстойник. +type CorpseStarchDipOutcome int + +const ( + DipBonusStarch CorpseStarchDipOutcome = iota + DipMachineSpiritBlessing + DipNothing + DipCorruption +) + +// DipChainsword бросает цепной меч в отстойник; roll — случайное +// число в [0,1), передаётся вызывающим кодом (реальный TUI передаёт +// rand.Float64(), тесты — фиксированные значения для детерминизма). +func (g *CorpseStarchGameState) DipChainsword(now time.Time, roll float64) CorpseStarchDipOutcome { + switch { + case roll < 0.5: + bonus := 5 + roll*70 // немного разнообразия внутри диапазона [5,40) + g.earn(bonus) + g.addLog("corpsestarch.log.dip_bonus") + return DipBonusStarch + case roll < 0.75: + g.BonusMultiplier = 2 + g.BonusUntil = now.Add(30 * time.Second) + g.addLog("corpsestarch.log.dip_blessing") + return DipMachineSpiritBlessing + case roll < 0.9: + g.addLog("corpsestarch.log.dip_nothing") + return DipNothing + default: + g.Corruption++ + penalty := g.Starch * 0.1 + g.Starch -= penalty + g.addLog("corpsestarch.log.dip_corruption") + return DipCorruption + } +} + +// StartMission запускает миссию defIdx, если она открыта и сейчас +// нет другой активной миссии. +func (g *CorpseStarchGameState) StartMission(now time.Time, defIdx int) bool { + if g.ActiveMission != nil { + return false + } + if defIdx < 0 || defIdx >= len(corpseStarchMissionDefs) { + return false + } + if g.TotalEarned < corpseStarchMissionDefs[defIdx].UnlockAt { + return false + } + g.ActiveMission = &corpseStarchMission{DefIdx: defIdx, StartedAt: now} + return true +} + +// MissionRemaining возвращает оставшееся время активной миссии (0, +// если миссии нет или она уже завершена). +func (g *CorpseStarchGameState) MissionRemaining(now time.Time) time.Duration { + if g.ActiveMission == nil { + return 0 + } + def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] + remaining := def.Duration - now.Sub(g.ActiveMission.StartedAt) + if remaining < 0 { + return 0 + } + return remaining +} + +// CollectMission забирает награду завершённой миссии (если она уже +// истекла); roll — случайное число в [0,1) для определения награды +// в диапазоне [RewardMin,RewardMax). Возвращает (награда, true) при +// успехе. +func (g *CorpseStarchGameState) CollectMission(now time.Time, roll float64) (float64, bool) { + if g.ActiveMission == nil { + return 0, false + } + if g.MissionRemaining(now) > 0 { + return 0, false + } + def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] + reward := def.RewardMin + roll*(def.RewardMax-def.RewardMin) + g.earn(reward) + g.MissionsDone++ + g.ActiveMission = nil + g.addLog("corpsestarch.log.mission_done") + return reward, true +} + +// ServitorsUnlocked, ShopUnlocked, SumpUnlocked, MissionsUnlocked — +// проверки открытия соответствующих разделов интерфейса. +func (g *CorpseStarchGameState) ServitorsUnlocked() bool { + return g.TotalEarned >= corpseStarchUnlockServitors +} +func (g *CorpseStarchGameState) ShopUnlocked() bool { + return g.TotalEarned >= corpseStarchUnlockShop +} +func (g *CorpseStarchGameState) SumpUnlocked() bool { + return g.TotalEarned >= corpseStarchUnlockSump +} +func (g *CorpseStarchGameState) MissionsUnlocked() bool { + return g.TotalEarned >= corpseStarchUnlockMissions +} + +// ServitorUnlocked проверяет, виден ли конкретный тир idx (может +// быть ещё не открыт, даже если раздел "Сервиторы" уже виден). +func (g *CorpseStarchGameState) ServitorUnlocked(idx int) bool { + return g.TotalEarned >= corpseStarchServitorDefs[idx].UnlockAt +} + +// MissionUnlocked проверяет, видна ли конкретная миссия idx. +func (g *CorpseStarchGameState) MissionUnlocked(idx int) bool { + return g.TotalEarned >= corpseStarchMissionDefs[idx].UnlockAt +} diff --git a/corpsestarch_game_test.go b/corpsestarch_game_test.go new file mode 100644 index 0000000..ae759e9 --- /dev/null +++ b/corpsestarch_game_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "testing" + "time" +) + +func csT(offsetSec int) time.Time { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + return base.Add(time.Duration(offsetSec) * time.Second) +} + +func TestNewCorpseStarchGameInitialState(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + if g.Starch != 0 || g.TotalEarned != 0 { + t.Fatalf("новая партия должна начинаться с нулевого запаса") + } + if g.ClickYield != 1 { + t.Errorf("ожидался базовый доход клика 1, получено %v", g.ClickYield) + } + if len(g.ServitorCounts) != len(corpseStarchServitorDefs) { + t.Errorf("количество счётчиков сервиторов должно совпадать с числом тиров") + } +} + +func TestRequisitionAddsClickYield(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.Requisition(csT(0)) + if g.Starch != 1 || g.TotalEarned != 1 { + t.Errorf("ожидался запас 1 после одной реквизиции, получено %v/%v", g.Starch, g.TotalEarned) + } + g.Requisition(csT(1)) + if g.Starch != 2 { + t.Errorf("ожидался запас 2 после двух реквизиций, получено %v", g.Starch) + } +} + +func TestTickAccruesPassiveIncome(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.ServitorCounts[0] = 2 // 2 сервочерепа по 0.5/сек = 1/сек + g.Tick(csT(10)) + if g.Starch != 10 { + t.Errorf("ожидался запас 10 после 10 секунд по 1/сек, получено %v", g.Starch) + } + if g.TotalEarned != 10 { + t.Errorf("TotalEarned тоже должен был вырасти на 10, получено %v", g.TotalEarned) + } +} + +func TestTickWithNoElapsedTimeDoesNothing(t *testing.T) { + g := NewCorpseStarchGame(csT(5)) + g.ServitorCounts[0] = 5 + g.Tick(csT(5)) + if g.Starch != 0 { + t.Errorf("без прошедшего времени доход не должен начисляться, получено %v", g.Starch) + } +} + +func TestSectionsUnlockProgressively(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + if g.ServitorsUnlocked() || g.ShopUnlocked() || g.SumpUnlocked() || g.MissionsUnlocked() { + t.Fatalf("в начале партии ни один раздел не должен быть открыт") + } + + g.TotalEarned = corpseStarchUnlockServitors + if !g.ServitorsUnlocked() { + t.Errorf("сервиторы должны открыться на пороге %v", corpseStarchUnlockServitors) + } + if g.ShopUnlocked() { + t.Errorf("магазин не должен быть открыт раньше своего порога") + } + + g.TotalEarned = corpseStarchUnlockMissions + if !g.ShopUnlocked() || !g.SumpUnlocked() || !g.MissionsUnlocked() { + t.Errorf("на пороге зачисток все более ранние разделы тоже должны быть открыты") + } +} + +func TestBuyServitorRespectsUnlockAndCost(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.Starch = 5 + if g.BuyServitor(0) { + t.Fatalf("покупка не должна была пройти при нехватке средств") + } + g.Starch = 10 + if !g.BuyServitor(0) { + t.Fatalf("покупка должна была пройти при ровно достаточных средствах") + } + if g.ServitorCounts[0] != 1 { + t.Errorf("счётчик тира 0 должен был увеличиться") + } + if g.Starch != 0 { + t.Errorf("стоимость должна была списаться полностью, получено %v", g.Starch) + } + + g.Starch = 1000 + if g.BuyServitor(1) { + t.Errorf("тир 1 не должен быть доступен без достаточного TotalEarned") + } +} + +func TestServitorCostScalesUp(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + firstCost := g.servitorCost(0) + g.Starch = firstCost + g.BuyServitor(0) + secondCost := g.servitorCost(0) + if secondCost <= firstCost { + t.Errorf("стоимость следующей покупки должна расти, было %v стало %v", firstCost, secondCost) + } +} + +func TestBuyClickUpgrade(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.Starch = corpseStarchClickUpgradeBaseCost + before := g.ClickYield + if !g.BuyClickUpgrade() { + t.Fatalf("апгрейд должен был пройти при достаточных средствах") + } + if g.ClickYield <= before { + t.Errorf("доход клика должен был вырасти") + } + if g.Starch != 0 { + t.Errorf("стоимость апгрейда должна была списаться полностью") + } +} + +func TestDipChainswordBonusOutcome(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + before := g.Starch + outcome := g.DipChainsword(csT(0), 0.1) + if outcome != DipBonusStarch { + t.Fatalf("ожидался исход DipBonusStarch при малом roll, получено %v", outcome) + } + if g.Starch <= before { + t.Errorf("запас должен был вырасти при бонусном исходе") + } +} + +func TestDipChainswordBlessingGrantsTemporaryMultiplier(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + outcome := g.DipChainsword(csT(0), 0.6) + if outcome != DipMachineSpiritBlessing { + t.Fatalf("ожидался исход DipMachineSpiritBlessing, получено %v", outcome) + } + if g.currentClickYield(csT(0)) != g.ClickYield*2 { + t.Errorf("во время благословения доход клика должен удваиваться") + } + if g.currentClickYield(csT(31)) != g.ClickYield { + t.Errorf("после истечения 30 секунд бонус должен был закончиться") + } +} + +func TestDipChainswordNothingOutcome(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + before := g.Starch + outcome := g.DipChainsword(csT(0), 0.8) + if outcome != DipNothing { + t.Fatalf("ожидался исход DipNothing, получено %v", outcome) + } + if g.Starch != before { + t.Errorf("запас не должен был измениться при пустом исходе") + } +} + +func TestDipChainswordCorruptionOutcome(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.Starch = 100 + outcome := g.DipChainsword(csT(0), 0.95) + if outcome != DipCorruption { + t.Fatalf("ожидался исход DipCorruption, получено %v", outcome) + } + if g.Corruption != 1 { + t.Errorf("скверна должна была увеличиться на 1") + } + if g.Starch >= 100 { + t.Errorf("запас должен был уменьшиться от порчи, получено %v", g.Starch) + } +} + +func TestStartMissionRequiresUnlockAndNoActiveMission(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + if g.StartMission(csT(0), 0) { + t.Fatalf("миссия не должна была начаться без открытого порога") + } + g.TotalEarned = corpseStarchUnlockMissions + if !g.StartMission(csT(0), 0) { + t.Fatalf("миссия должна была начаться при открытом пороге") + } + if g.StartMission(csT(1), 1) { + t.Errorf("нельзя начать вторую миссию, пока активна первая") + } +} + +func TestMissionRemainingAndCollect(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + g.TotalEarned = corpseStarchUnlockMissions + g.StartMission(csT(0), 0) // "vermin", 20 секунд + + if g.MissionRemaining(csT(5)) <= 0 { + t.Fatalf("через 5 секунд миссия ещё не должна была закончиться") + } + if _, ok := g.CollectMission(csT(5), 0.5); ok { + t.Errorf("нельзя забрать награду до завершения миссии") + } + + if g.MissionRemaining(csT(25)) != 0 { + t.Errorf("через 25 секунд миссия (20 сек) должна была завершиться") + } + reward, ok := g.CollectMission(csT(25), 0.5) + if !ok { + t.Fatalf("награда должна была быть получена после завершения") + } + def := corpseStarchMissionDefs[0] + wantReward := def.RewardMin + 0.5*(def.RewardMax-def.RewardMin) + if reward != wantReward { + t.Errorf("ожидалась награда %v, получено %v", wantReward, reward) + } + if g.ActiveMission != nil { + t.Errorf("активная миссия должна была сброситься после получения награды") + } + if g.MissionsDone != 1 { + t.Errorf("счётчик завершённых миссий должен был увеличиться") + } +} + +func TestLogTrimsToLimit(t *testing.T) { + g := NewCorpseStarchGame(csT(0)) + for i := 0; i < corpseStarchLogLimit+5; i++ { + g.addLog("test.key") + } + if len(g.Log) != corpseStarchLogLimit { + t.Errorf("лог должен обрезаться до лимита %d, получено %d", corpseStarchLogLimit, len(g.Log)) + } +} diff --git a/corpsestarch_save.go b/corpsestarch_save.go new file mode 100644 index 0000000..cb1238d --- /dev/null +++ b/corpsestarch_save.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// ПРИМЕЧАНИЕ: сохранение реализовано только для этой игры — она +// единственная в коллекции без чёткого конца партии, поэтому жалко +// сбрасывать прогресс при каждом выходе в меню. Остальные 8 игр +// коллекции по-прежнему не сохраняются между запусками — обычно в +// них и не нужно (партия проходится за один присест). + +// ErrCorpseStarchNoSave возвращается LoadCorpseStarchGame, если +// файл сохранения не найден (новая партия должна начаться с нуля). +var ErrCorpseStarchNoSave = errors.New("файл сохранения не найден") + +// corpseStarchSavePath возвращает путь к файлу сохранения в +// пользовательской конфигурационной директории (например, +// ~/.config/go-games-collection/corpsestarch_save.json на Linux). +func corpseStarchSavePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "go-games-collection", "corpsestarch_save.json"), nil +} + +// SaveCorpseStarchGame сохраняет текущее состояние партии на диск, +// создавая директорию сохранения при необходимости. +func SaveCorpseStarchGame(g *CorpseStarchGameState) error { + path, err := corpseStarchSavePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// LoadCorpseStarchGame загружает сохранённую партию с диска. +// Возвращает ErrCorpseStarchNoSave, если файла сохранения нет — +// вызывающий код должен в этом случае начать новую партию. +func LoadCorpseStarchGame() (*CorpseStarchGameState, error) { + path, err := corpseStarchSavePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrCorpseStarchNoSave + } + return nil, err + } + var g CorpseStarchGameState + if err := json.Unmarshal(data, &g); err != nil { + return nil, err + } + if len(g.ServitorCounts) < len(corpseStarchServitorDefs) { + grown := make([]int, len(corpseStarchServitorDefs)) + copy(grown, g.ServitorCounts) + g.ServitorCounts = grown + } + if g.BonusMultiplier == 0 { + g.BonusMultiplier = 1 + } + return &g, nil +} + +// DeleteCorpseStarchSave удаляет файл сохранения, если он есть +// (используется для явного сброса прогресса). Отсутствие файла не +// считается ошибкой. +func DeleteCorpseStarchSave() error { + path, err := corpseStarchSavePath() + if err != nil { + return err + } + err = os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/corpsestarch_save_test.go b/corpsestarch_save_test.go new file mode 100644 index 0000000..505492b --- /dev/null +++ b/corpsestarch_save_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "os" + "testing" +) + +// withTempConfigDir подменяет XDG_CONFIG_HOME на временную — так +// os.UserConfigDir() на Linux/macOS будет смотреть в неё, и тесты +// не трогают реальный конфиг пользователя. +func withTempConfigDir(t *testing.T) { + t.Helper() + dir := t.TempDir() + oldXDG, hadXDG := os.LookupEnv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", dir) + t.Cleanup(func() { + if hadXDG { + os.Setenv("XDG_CONFIG_HOME", oldXDG) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }) +} + +func TestLoadCorpseStarchGameWithNoSaveFile(t *testing.T) { + withTempConfigDir(t) + _, err := LoadCorpseStarchGame() + if err != ErrCorpseStarchNoSave { + t.Fatalf("ожидалась ErrCorpseStarchNoSave при отсутствии файла, получено: %v", err) + } +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + withTempConfigDir(t) + g := NewCorpseStarchGame(csT(0)) + g.Starch = 123.5 + g.TotalEarned = 999 + g.ClickYield = 4 + g.ClickUpgrades = 3 + g.ServitorCounts[0] = 5 + g.ServitorCounts[1] = 2 + g.Corruption = 2 + g.MissionsDone = 1 + g.Log = []string{"a", "b", "c"} + + if err := SaveCorpseStarchGame(g); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + loaded, err := LoadCorpseStarchGame() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + if loaded.Starch != g.Starch || loaded.TotalEarned != g.TotalEarned { + t.Errorf("запас/накопленное не совпадают после загрузки: %+v vs %+v", loaded, g) + } + if loaded.ClickYield != g.ClickYield || loaded.ClickUpgrades != g.ClickUpgrades { + t.Errorf("параметры клика не совпадают после загрузки") + } + if loaded.ServitorCounts[0] != 5 || loaded.ServitorCounts[1] != 2 { + t.Errorf("счётчики сервиторов не совпадают после загрузки: %v", loaded.ServitorCounts) + } + if loaded.Corruption != 2 || loaded.MissionsDone != 1 { + t.Errorf("скверна/завершённые миссии не совпадают после загрузки") + } + if len(loaded.Log) != 3 { + t.Errorf("журнал должен был сохраниться полностью, получено %v", loaded.Log) + } +} + +func TestSaveAndLoadPreservesActiveMission(t *testing.T) { + withTempConfigDir(t) + g := NewCorpseStarchGame(csT(0)) + g.TotalEarned = corpseStarchUnlockMissions + g.StartMission(csT(100), 1) + + if err := SaveCorpseStarchGame(g); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + loaded, err := LoadCorpseStarchGame() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + if loaded.ActiveMission == nil { + t.Fatalf("активная миссия должна была сохраниться") + } + if loaded.ActiveMission.DefIdx != 1 { + t.Errorf("ожидался индекс миссии 1, получено %d", loaded.ActiveMission.DefIdx) + } + if !loaded.ActiveMission.StartedAt.Equal(csT(100)) { + t.Errorf("время начала миссии должно было сохраниться точно, получено %v", loaded.ActiveMission.StartedAt) + } +} + +// TestOfflineProgressAccruesOnLoad проверяет ключевое ожидаемое +// поведение: если между сохранением и загрузкой прошло реальное +// время, пассивный доход от сервиторов должен по-честному +// начислиться при первом Tick после загрузки — ровно так же, как +// если бы игрок всё это время не отходил от экрана. +func TestOfflineProgressAccruesOnLoad(t *testing.T) { + withTempConfigDir(t) + g := NewCorpseStarchGame(csT(0)) + g.ServitorCounts[0] = 10 // 10 * 0.5/сек = 5/сек + if err := SaveCorpseStarchGame(g); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + + loaded, err := LoadCorpseStarchGame() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + loaded.Tick(csT(100)) + if loaded.Starch != 500 { + t.Errorf("ожидался начисленный доход за 100 секунд отсутствия (500), получено %v", loaded.Starch) + } +} + +func TestDeleteCorpseStarchSave(t *testing.T) { + withTempConfigDir(t) + g := NewCorpseStarchGame(csT(0)) + if err := SaveCorpseStarchGame(g); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + if err := DeleteCorpseStarchSave(); err != nil { + t.Fatalf("неожиданная ошибка удаления: %v", err) + } + if _, err := LoadCorpseStarchGame(); err != ErrCorpseStarchNoSave { + t.Errorf("после удаления файла загрузка должна вернуть ErrCorpseStarchNoSave, получено: %v", err) + } + + if err := DeleteCorpseStarchSave(); err != nil { + t.Errorf("повторное удаление отсутствующего файла не должно быть ошибкой, получено: %v", err) + } +} + +func TestLoadCorruptedSaveFileReturnsError(t *testing.T) { + withTempConfigDir(t) + path, err := corpseStarchSavePath() + if err != nil { + t.Fatalf("неожиданная ошибка получения пути: %v", err) + } + dir := path[:len(path)-len("/corpsestarch_save.json")] + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("неожиданная ошибка создания директории: %v", err) + } + if err := os.WriteFile(path, []byte("это не json"), 0o644); err != nil { + t.Fatalf("неожиданная ошибка записи повреждённого файла: %v", err) + } + + if _, err := LoadCorpseStarchGame(); err == nil { + t.Fatalf("ожидалась ошибка при загрузке повреждённого файла") + } +} + +func TestSaveOverwritesPreviousSave(t *testing.T) { + withTempConfigDir(t) + g1 := NewCorpseStarchGame(csT(0)) + g1.Starch = 10 + SaveCorpseStarchGame(g1) + + g2 := NewCorpseStarchGame(csT(0)) + g2.Starch = 999 + SaveCorpseStarchGame(g2) + + loaded, err := LoadCorpseStarchGame() + if err != nil { + t.Fatalf("неожиданная ошибка загрузки: %v", err) + } + if loaded.Starch != 999 { + t.Errorf("повторное сохранение должно было перезаписать файл, получено %v", loaded.Starch) + } +} diff --git a/corpsestarch_tui.go b/corpsestarch_tui.go new file mode 100644 index 0000000..e1c231e --- /dev/null +++ b/corpsestarch_tui.go @@ -0,0 +1,285 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// corpseStarchTickMsg — сигнал раз в секунду для начисления +// пассивного дохода в реальном времени, пока открыт экран игры. +type corpseStarchTickMsg struct{ At time.Time } + +const corpseStarchTickInterval = 1 * time.Second + +// corpseStarchAutosaveMsg — сигнал периодического автосохранения. +type corpseStarchAutosaveMsg struct{} + +const corpseStarchAutosaveInterval = 15 * time.Second + +// CorpseStarchModel — TUI поверх CorpseStarchGameState. +type CorpseStarchModel struct { + game *CorpseStarchGameState + + message string + + quitting bool + backToMenu bool +} + +// NewCorpseStarchModel загружает сохранённую партию, если она есть +// (начисляя честный пассивный доход за время отсутствия), либо +// создаёт новую. +func NewCorpseStarchModel() CorpseStarchModel { + now := time.Now() + g, err := LoadCorpseStarchGame() + if err != nil { + return CorpseStarchModel{game: NewCorpseStarchGame(now)} + } + before := g.Starch + g.Tick(now) // честно начисляем доход за время, пока партия была закрыта + m := CorpseStarchModel{game: g} + if earned := g.Starch - before; earned >= 1 { + m.message = Tf("corpsestarch.msg.welcome_back", formatStarch(earned)) + } + return m +} + +func (m CorpseStarchModel) Init() tea.Cmd { + return tea.Batch(corpseStarchScheduleTick(), corpseStarchScheduleAutosave()) +} + +func corpseStarchScheduleTick() tea.Cmd { + return tea.Tick(corpseStarchTickInterval, func(t time.Time) tea.Msg { return corpseStarchTickMsg{At: t} }) +} + +func corpseStarchScheduleAutosave() tea.Cmd { + return tea.Tick(corpseStarchAutosaveInterval, func(time.Time) tea.Msg { return corpseStarchAutosaveMsg{} }) +} + +func (m *CorpseStarchModel) setInfo(key string) { + m.message = T(key) +} + +// corpseStarchAction — один пункт динамического пронумерованного +// меню действий: что показать и что сделать по нажатию цифры. +type corpseStarchAction struct { + Label string + Handle func(m *CorpseStarchModel, now time.Time) +} + +// buildActions собирает список доступных прямо сейчас действий — +// от этого зависит, какая цифра что делает; список пересобирается +// на каждый рендер/обработку ввода, поэтому нумерация всегда +// соответствует актуально показанным пунктам. +func (m CorpseStarchModel) buildActions(now time.Time) []corpseStarchAction { + g := m.game + var actions []corpseStarchAction + + actions = append(actions, corpseStarchAction{ + Label: Tf("corpsestarch.action.requisition", formatStarch(g.currentClickYield(now))), + Handle: func(m *CorpseStarchModel, now time.Time) { + m.game.Requisition(now) + m.setInfo("corpsestarch.msg.requisitioned") + }, + }) + + if g.ServitorsUnlocked() { + for i, def := range corpseStarchServitorDefs { + if !g.ServitorUnlocked(i) { + continue + } + i := i + cost := g.servitorCost(i) + actions = append(actions, corpseStarchAction{ + Label: Tf("corpsestarch.action.buy_servitor", T(def.NameKey), g.ServitorCounts[i], formatStarch(cost)), + Handle: func(m *CorpseStarchModel, now time.Time) { + if m.game.BuyServitor(i) { + m.setInfo("corpsestarch.msg.servitor_bought") + } else { + m.setInfo("corpsestarch.msg.cannot_afford") + } + }, + }) + } + } + + if g.ShopUnlocked() { + cost := g.clickUpgradeCost() + actions = append(actions, corpseStarchAction{ + Label: Tf("corpsestarch.action.upgrade_click", formatStarch(cost)), + Handle: func(m *CorpseStarchModel, now time.Time) { + if m.game.BuyClickUpgrade() { + m.setInfo("corpsestarch.msg.upgrade_bought") + } else { + m.setInfo("corpsestarch.msg.cannot_afford") + } + }, + }) + } + + if g.SumpUnlocked() { + actions = append(actions, corpseStarchAction{ + Label: T("corpsestarch.action.dip"), + Handle: func(m *CorpseStarchModel, now time.Time) { + outcome := m.game.DipChainsword(now, rand.Float64()) + switch outcome { + case DipBonusStarch: + m.setInfo("corpsestarch.log.dip_bonus") + case DipMachineSpiritBlessing: + m.setInfo("corpsestarch.log.dip_blessing") + case DipNothing: + m.setInfo("corpsestarch.log.dip_nothing") + case DipCorruption: + m.setInfo("corpsestarch.log.dip_corruption") + } + }, + }) + } + + if g.MissionsUnlocked() { + if g.ActiveMission == nil { + for i, def := range corpseStarchMissionDefs { + if !g.MissionUnlocked(i) { + continue + } + i := i + actions = append(actions, corpseStarchAction{ + Label: Tf("corpsestarch.action.start_mission", T(def.NameKey), formatDuration(def.Duration)), + Handle: func(m *CorpseStarchModel, now time.Time) { + if m.game.StartMission(now, i) { + m.setInfo("corpsestarch.msg.mission_started") + } + }, + }) + } + } else if g.MissionRemaining(now) == 0 { + actions = append(actions, corpseStarchAction{ + Label: T("corpsestarch.action.collect_mission"), + Handle: func(m *CorpseStarchModel, now time.Time) { + if _, ok := m.game.CollectMission(now, rand.Float64()); ok { + m.setInfo("corpsestarch.log.mission_done") + } + }, + }) + } + } + + return actions +} + +func (m CorpseStarchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + case corpseStarchTickMsg: + m.game.Tick(msg.At) + return m, corpseStarchScheduleTick() + case corpseStarchAutosaveMsg: + _ = SaveCorpseStarchGame(m.game) + return m, corpseStarchScheduleAutosave() + } + return m, nil +} + +func (m CorpseStarchModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "ctrl+c" { + _ = SaveCorpseStarchGame(m.game) + m.quitting = true + return m, tea.Quit + } + if key == "q" { + _ = SaveCorpseStarchGame(m.game) + m.backToMenu = true + return m, nil + } + + now := time.Now() + actions := m.buildActions(now) + for i := 0; i < len(actions) && i < 9; i++ { + if key == fmt.Sprintf("%d", i+1) { + actions[i].Handle(&m, now) + return m, nil + } + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +// formatStarch форматирует значение ресурса компактно (без длинного +// хвоста дробной части). +func formatStarch(v float64) string { + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%.1f", v) +} + +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + m := int(d.Minutes()) + s := int(d.Seconds()) % 60 + if m > 0 { + return fmt.Sprintf("%dm%02ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func (m CorpseStarchModel) View() string { + if m.quitting { + return T("common.goodbye") + } + + g := m.game + now := time.Now() + var b strings.Builder + + fmt.Fprintln(&b, titleStyle.Render(T("corpsestarch.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("corpsestarch.header.starch", formatStarch(g.Starch), formatStarch(g.TotalEarned))) + if g.SumpUnlocked() { + fmt.Fprintln(&b, Tf("corpsestarch.header.corruption", g.Corruption)) + } + fmt.Fprintln(&b) + + if g.MissionsUnlocked() && g.ActiveMission != nil { + def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] + remaining := g.MissionRemaining(now) + if remaining > 0 { + fmt.Fprintln(&b, Tf("corpsestarch.mission.inprogress", T(def.NameKey), formatDuration(remaining))) + } else { + fmt.Fprintln(&b, winStyle.Render(Tf("corpsestarch.mission.ready", T(def.NameKey)))) + } + fmt.Fprintln(&b) + } + + actions := m.buildActions(now) + for i, a := range actions { + if i >= 9 { + break + } + fmt.Fprintf(&b, "[%d] %s\n", i+1, a.Label) + } + fmt.Fprintln(&b) + + if len(g.Log) > 0 { + fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.log.title"))) + for _, key := range g.Log { + fmt.Fprintln(&b, dimStyle.Render(" "+T(key))) + } + fmt.Fprintln(&b) + } + + if m.message != "" { + fmt.Fprintln(&b, infoStyle.Render(m.message)) + fmt.Fprintln(&b) + } + + fmt.Fprintln(&b, dimStyle.Render(T("corpsestarch.hint"))) + return b.String() +} diff --git a/corpsestarch_tui_test.go b/corpsestarch_tui_test.go new file mode 100644 index 0000000..5cd1b6d --- /dev/null +++ b/corpsestarch_tui_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "testing" + "time" +) + +func TestCorpseStarchModelRequisitionViaKey(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + next, _ := m.Update(key("1")) + m = next.(CorpseStarchModel) + if m.game.Starch != 1 { + t.Fatalf("ожидался запас 1 после нажатия [1], получено %v", m.game.Starch) + } +} + +func TestCorpseStarchModelActionsExpandAsUnlocked(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + if len(m.buildActions(time.Now())) != 1 { + t.Fatalf("в начале должно быть только 1 действие (реквизиция)") + } + + m.game.TotalEarned = corpseStarchUnlockServitors + if len(m.buildActions(time.Now())) != 2 { + t.Fatalf("после открытия сервиторов должно быть 2 действия") + } + + m.game.TotalEarned = corpseStarchUnlockMissions + actions := m.buildActions(time.Now()) + if len(actions) != 8 { + t.Fatalf("ожидалось 8 действий при полностью открытых (кроме 3 тира) разделах, получено %d", len(actions)) + } +} + +func TestCorpseStarchModelBuyServitorViaKey(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + m.game.TotalEarned = corpseStarchUnlockServitors + m.game.Starch = 100 + + next, _ := m.Update(key("2")) + m = next.(CorpseStarchModel) + if m.game.ServitorCounts[0] != 1 { + t.Fatalf("ожидалась покупка сервочерпа, получено count=%d", m.game.ServitorCounts[0]) + } +} + +func TestCorpseStarchModelTickAccruesIncome(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + m.game.ServitorCounts[0] = 10 + past := m.game.LastTick.Add(2 * time.Second) + + next, _ := m.Update(corpseStarchTickMsg{At: past}) + m = next.(CorpseStarchModel) + if m.game.Starch != 10 { + t.Errorf("ожидался запас 10 после тика на 2 секунды при 5/сек, получено %v", m.game.Starch) + } +} + +func TestCorpseStarchModelQReturnsToMenu(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + next, _ := m.Update(key("q")) + m = next.(CorpseStarchModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestCorpseStarchModelMissionFlowViaKeys(t *testing.T) { + withTempConfigDir(t) + m := NewCorpseStarchModel() + m.game.TotalEarned = corpseStarchUnlockMissions + m.game.Starch = 0 + + actions := m.buildActions(time.Now()) + missionActionIdx := -1 + for i, a := range actions { + if a.Label == Tf("corpsestarch.action.start_mission", T("corpsestarch.mission.vermin"), formatDuration(corpseStarchMissionDefs[0].Duration)) { + missionActionIdx = i + break + } + } + if missionActionIdx == -1 { + t.Fatalf("не нашли действие запуска первой миссии среди %d действий", len(actions)) + } + + next, _ := m.Update(key(string(rune('1' + missionActionIdx)))) + m = next.(CorpseStarchModel) + if m.game.ActiveMission == nil { + t.Fatalf("миссия должна была начаться") + } + + finished := m.game.ActiveMission.StartedAt.Add(corpseStarchMissionDefs[0].Duration + time.Second) + if m.game.MissionRemaining(finished) != 0 { + t.Fatalf("миссия должна была завершиться к этому моменту") + } + + collectActions := m.buildActions(finished) + collectIdx := -1 + for i, a := range collectActions { + if a.Label == T("corpsestarch.action.collect_mission") { + collectIdx = i + break + } + } + if collectIdx == -1 { + t.Fatalf("не нашли действие сбора награды") + } + collectActions[collectIdx].Handle(&m, finished) + if m.game.ActiveMission != nil { + t.Errorf("миссия должна была завершиться после сбора награды") + } + if m.game.MissionsDone != 1 { + t.Errorf("счётчик завершённых миссий должен был увеличиться") + } +} diff --git a/durak_bot.go b/durak_bot.go index 0ff3277..49396ef 100644 --- a/durak_bot.go +++ b/durak_bot.go @@ -10,17 +10,31 @@ var durakNamePool = []string{ "Иван", "Пётр", "Алексей", "Дмитрий", "Сергей", "Николай", "Андрей", "Михаил", } -// pickDurakBotName выбирает случайное имя из durakNamePool, по -// возможности не повторяющее уже занятые (used). +// durakNamePoolEN — англоязычная версия того же пула. +var durakNamePoolEN = []string{ + "John", "Peter", "Alex", "James", "Simon", "Nicholas", "Andrew", "Michael", +} + +func activeDurakNamePool() []string { + if CurrentLang() == LangEN { + return durakNamePoolEN + } + return durakNamePool +} + +// pickDurakBotName выбирает случайное имя из активного (для +// текущего языка) пула, по возможности не повторяющее уже занятые +// (used). func pickDurakBotName(used map[string]bool) string { - free := make([]string, 0, len(durakNamePool)) - for _, name := range durakNamePool { + pool := activeDurakNamePool() + free := make([]string, 0, len(pool)) + for _, name := range pool { if !used[name] { free = append(free, name) } } if len(free) == 0 { - free = durakNamePool + free = pool } return free[rand.Intn(len(free))] } diff --git a/durak_tui.go b/durak_tui.go index 7f1bdfd..26e6e11 100644 --- a/durak_tui.go +++ b/durak_tui.go @@ -35,7 +35,7 @@ func NewDurakModel(numPlayers int) DurakModel { numPlayers = 6 } botNames := newDurakBotNames(numPlayers - 1) - names := append([]string{"Вы"}, botNames...) + names := append([]string{T("common.you")}, botNames...) return DurakModel{ game: NewDurakGame(names, 0), @@ -96,7 +96,7 @@ func (m *DurakModel) setInfo(format string, args ...interface{}) { } func (m *DurakModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -119,10 +119,10 @@ func (m DurakModel) handleBotMove() (tea.Model, tea.Cmd) { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { - m.setError(fmt.Errorf("бот %s: %w", m.game.Players[actor].Name, err)) + m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[actor].Name, err)) return m, nil } - m.setInfo("%s сходил(а).", m.game.Players[actor].Name) + m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[actor].Name) if m.game.Phase != DurakPhaseGameOver && m.currentActor() == m.humanIdx { m.resetSelection() } @@ -191,7 +191,7 @@ func (m DurakModel) handleAttackKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы подкинули %s.", card) + m.setInfo(T("durak.msg.threw_in"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } @@ -220,7 +220,7 @@ func (m DurakModel) handleThrowInKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы подкинули %s.", card) + m.setInfo(T("durak.msg.threw_in"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } @@ -230,7 +230,7 @@ func (m DurakModel) handleThrowInKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы пасовали.") + m.setInfo(T("durak.msg.passed")) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } @@ -259,7 +259,7 @@ func (m DurakModel) handleDefendKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы отбились картой %s.", card) + m.setInfo(T("durak.msg.defended"), card) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } @@ -269,7 +269,7 @@ func (m DurakModel) handleDefendKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы забрали стол себе в руку.") + m.setInfo(T("durak.msg.took")) if m.game.Phase != DurakPhaseGameOver { m.resetSelection() } @@ -283,7 +283,7 @@ func (m DurakModel) handleDefendKey(key string) (tea.Model, tea.Cmd) { func (m DurakModel) renderHand() string { hand := m.currentHand() if len(hand) == 0 { - return dimStyle.Render("(нет карт)") + return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { @@ -298,7 +298,7 @@ func (m DurakModel) renderHand() string { func (m DurakModel) renderTable() string { if len(m.game.Table) == 0 { - return dimStyle.Render("(стол пуст)") + return dimStyle.Render(T("tonk.table_empty")) } parts := make([]string, len(m.game.Table)) for i, pair := range m.game.Table { @@ -324,14 +324,11 @@ func (m DurakModel) renderPlayers() string { marker = "D " } name := p.Name - if i == m.humanIdx { - name += " (вы)" - } status := "" if p.Out { - status = " [отбился]" + status = T("durak.status_out") } - line := fmt.Sprintf("%s%-20s карт: %-2d%s", marker, name, len(p.Hand), status) + line := fmt.Sprintf(T("durak.player_line"), marker, name, len(p.Hand), status) if i == m.game.AttackerIdx || i == m.game.DefenderIdx || (m.game.Phase == DurakPhaseThrowIn && i == m.game.CurrentThrower()) { line = turnStyle.Render(line) } @@ -342,44 +339,45 @@ func (m DurakModel) renderPlayers() string { func (m DurakModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== ДУРАК ===")) + fmt.Fprintln(&b, titleStyle.Render(T("durak.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) + fmt.Fprintln(&b, dimStyle.Render(T("durak.legend"))) fmt.Fprintln(&b) trumpStr := renderCard(m.game.TrumpCard) - fmt.Fprintf(&b, "Козырь: %s В колоде: %d карт\n\n", trumpStr, len(m.game.Talon)) + fmt.Fprintf(&b, T("durak.status_line"), trumpStr, len(m.game.Talon)) - fmt.Fprintln(&b, "Стол (атака/защита):") + fmt.Fprintln(&b, T("durak.table_label")) fmt.Fprintln(&b, m.renderTable()) fmt.Fprintln(&b) if m.game.Phase == DurakPhaseGameOver { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) - fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("durak.hint.gameover"))) return b.String() } if m.currentActor() == m.humanIdx { - fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, T("common.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintln(&b) switch m.game.Phase { case DurakPhaseAttack: - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть карту (обязательный первый ход) [q] в меню") + fmt.Fprintln(&b, T("durak.hint.attack")) case DurakPhaseDefend: - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отбить карту [t] забрать стол [q] в меню") + fmt.Fprintln(&b, T("durak.hint.defend")) case DurakPhaseThrowIn: - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] подкинуть ещё карту [p] пасовать [q] в меню") + fmt.Fprintln(&b, T("durak.hint.throwin")) } } else { - fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.currentActor()].Name) + fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.currentActor()].Name) } fmt.Fprintln(&b) @@ -397,14 +395,14 @@ func (m DurakModel) View() string { func (m DurakModel) renderResult() string { res := m.game.Result if res.Stalemate { - return errorStyle.Render("Партия зашла в тупик (карты слишком долго не выходили из игры) и объявлена ничьей.") + return errorStyle.Render(T("durak.result.stalemate")) } if res.NoLoser { - return winStyle.Render("Ничья! Все избавились от карт одновременно — дурака нет.") + return winStyle.Render(T("durak.result.noloser")) } loserName := m.game.Players[res.Loser].Name if res.Loser == m.humanIdx { - return errorStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName)) + return errorStyle.Render(Tf("durak.result.loser", loserName)) } - return winStyle.Render(fmt.Sprintf("%s остался(лась) с картами на руках — дурак!", loserName)) + return winStyle.Render(Tf("durak.result.loser", loserName)) } diff --git a/game.go b/game.go index 2da239c..eb84292 100644 --- a/game.go +++ b/game.go @@ -67,6 +67,8 @@ const ( type RoundResult struct { DeadRound bool // колода и сброс исчерпаны, банк возвращён игрокам TonkCall bool // раунд завершён объявлением Тонка + DropCall bool // раунд завершён объявлением дропа + DropCaught bool // дроп был неудачным (объявившего "поймали") Winner int // индекс победителя (не имеет смысла при DeadRound) Pot int // размер банка на момент завершения раунда BalanceDeltas []int // изменение капитала каждого игрока за этот раунд @@ -281,6 +283,21 @@ func (g *GameState) DeclareTonk() (*RoundResult, error) { return g.endByTonk(g.CurrentPlayer), nil } +// Drop — объявить дроп: рискованная заявка "у меня самая низкая +// сумма очков за столом", доступная в НАЧАЛЕ хода, до взятия карты +// (в отличие от Тонка, порог веса руки не требуется — можно +// дропнуть с любой рукой). Если объявивший действительно строго +// ниже всех остальных — он забирает банк как за обычную победу +// (без множителя). Если нет — он "попался": банк достаётся +// игроку(ам) с реально самой низкой суммой, а сам объявивший вдобавок +// платит им штраф в размере ещё одной ставки (анте) сверху. +func (g *GameState) Drop() (*RoundResult, error) { + if g.Phase != PhaseDraw { + return nil, ErrWrongPhase + } + return g.endByDrop(g.CurrentPlayer), nil +} + // LayNewMeld выкладывает новую комбинацию на стол из карт руки // текущего игрока. func (g *GameState) LayNewMeld(cards []Card) error { @@ -458,6 +475,82 @@ func (g *GameState) endByTonk(declarer int) *RoundResult { return g.Result } +// endByDrop разрешает объявление дропа игроком dropper: сравнивает +// вес его руки с весами рук всех остальных игроков. +// - Если dropper строго ниже всех — он забирает банк как за +// обычную победу (без множителя). +// - Если нет — банк достаётся игроку(ам) с реально самой низкой +// суммой (при нескольких таких игроках банк делится поровну), а +// dropper вдобавок платит им штраф в размере ещё одной ставки +// (анте) сверху, разделённой между ними так же поровну. +func (g *GameState) endByDrop(dropper int) *RoundResult { + values := make([]int, len(g.Players)) + minValue := g.Players[dropper].HandValue() + for i, p := range g.Players { + values[i] = p.HandValue() + if values[i] < minValue { + minValue = values[i] + } + } + + var lowest []int + for i, v := range values { + if v == minValue { + lowest = append(lowest, i) + } + } + + pot := g.Pot + result := &RoundResult{DropCall: true, Pot: pot} + + if len(lowest) == 1 && lowest[0] == dropper { + // успешный дроп: объявивший действительно строго ниже всех + g.Players[dropper].Balance += pot + result.Winner = dropper + } else { + // дроп не удался — "попался"; банк и штраф достаются + // реальным обладателям самой низкой суммы (dropper среди них + // быть не может: либо его сумма не минимальна, либо она + // минимальна, но не единственна — в обоих случаях это не + // строго меньше всех остальных) + catchers := make([]int, 0, len(lowest)) + for _, i := range lowest { + if i != dropper { + catchers = append(catchers, i) + } + } + penalty := g.AnteAmount + if len(catchers) > 0 { + potShare := pot / len(catchers) + potRemainder := pot % len(catchers) + penaltyShare := penalty / len(catchers) + penaltyRemainder := penalty % len(catchers) + totalPenaltyPaid := 0 + for idx, i := range catchers { + share := potShare + if idx < potRemainder { + share++ + } + pShare := penaltyShare + if idx < penaltyRemainder { + pShare++ + } + g.Players[i].Balance += share + pShare + totalPenaltyPaid += pShare + } + g.Players[dropper].Balance -= totalPenaltyPaid + } + result.Winner = catchers[0] + result.DropCaught = true + } + + g.Pot = 0 + result.BalanceDeltas = g.balanceDeltasFromPreRound() + g.Result = result + g.Phase = PhaseGameOver + return result +} + // endDeadRound завершает раунд без победителя — колода и сброс // исчерпаны. Банк возвращается игрокам поровну (каждому — его // анте назад), чтобы никто не терял деньги в раунде без вины. diff --git a/game_test.go b/game_test.go index e2e68a9..a3d21c3 100644 --- a/game_test.go +++ b/game_test.go @@ -125,6 +125,123 @@ func TestDeadRoundRefundsAnte(t *testing.T) { } } +func TestDropSucceedsWhenStrictlyLowest(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B", "C"}, -1, 100, 10, 2) + // банк = 30 (3*10), капитал каждого сейчас 90 + g.Players[0].Hand = []Card{c(Two, Clubs)} // вес 2 — строго ниже всех + g.Players[1].Hand = []Card{c(King, Hearts), c(Nine, Diamonds)} // вес 14 + g.Players[2].Hand = []Card{c(Queen, Spades)} // вес 10 + g.CurrentPlayer = 0 + g.Phase = PhaseDraw + + res, err := g.Drop() + if err != nil { + t.Fatalf("неожиданная ошибка дропа: %v", err) + } + if !res.DropCall || res.DropCaught { + t.Fatalf("ожидался успешный дроп: %+v", res) + } + if res.Winner != 0 { + t.Errorf("ожидался победитель 0, получено %d", res.Winner) + } + // объявивший забирает банк целиком (без множителя): 90+30=120, + // дельта +20 относительно стартовых 100 + if g.Players[0].Balance != 120 { + t.Errorf("ожидался капитал 120 у объявившего, получено %d", g.Players[0].Balance) + } + if g.Players[1].Balance != 90 || g.Players[2].Balance != 90 { + t.Errorf("остальные должны были просто остаться при своём анте (90), получено %d и %d", + g.Players[1].Balance, g.Players[2].Balance) + } + if res.BalanceDeltas[0] != 20 || res.BalanceDeltas[1] != -10 || res.BalanceDeltas[2] != -10 { + t.Errorf("неверный расчёт изменения капитала при успешном дропе: %v", res.BalanceDeltas) + } +} + +func TestDropCaughtByLowerOpponent(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) + // банк = 20, капитал каждого сейчас 90 + g.Players[0].Hand = []Card{c(King, Hearts)} // вес 4 — думает, что мало + g.Players[1].Hand = []Card{c(Ace, Clubs)} // вес 1 — реально ниже + g.CurrentPlayer = 0 + g.Phase = PhaseDraw + + res, err := g.Drop() + if err != nil { + t.Fatalf("неожиданная ошибка дропа: %v", err) + } + if !res.DropCall || !res.DropCaught { + t.Fatalf("ожидался неудачный дроп (попался): %+v", res) + } + if res.Winner != 1 { + t.Errorf("ожидался победитель 1 (реально ниже), получено %d", res.Winner) + } + // игрок 1 получает банк (20) плюс штраф с объявившего (анте=10): + // 90+20+10=120, дельта +20 + if g.Players[1].Balance != 120 { + t.Errorf("ожидался капитал 120 у поймавшего, получено %d", g.Players[1].Balance) + } + // объявивший теряет анте (уже потрачено) и вдобавок платит штраф + // в размере анте: 90-10=80, дельта -20 относительно стартовых 100 + if g.Players[0].Balance != 80 { + t.Errorf("ожидался капитал 80 у объявившего (попался), получено %d", g.Players[0].Balance) + } + if res.BalanceDeltas[0] != -20 || res.BalanceDeltas[1] != 20 { + t.Errorf("неверный расчёт изменения капитала при неудачном дропе: %v", res.BalanceDeltas) + } +} + +func TestDropTieWithAnotherPlayerCountsAsCaught(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) + g.Players[0].Hand = []Card{c(Ace, Clubs)} // вес 1 + g.Players[1].Hand = []Card{c(Ace, Hearts)} // тоже вес 1 — ничья не в пользу объявившего + g.CurrentPlayer = 0 + g.Phase = PhaseDraw + + res, err := g.Drop() + if err != nil { + t.Fatalf("неожиданная ошибка дропа: %v", err) + } + if !res.DropCaught { + t.Errorf("при равенстве весов дроп должен считаться неудачным (нужно строго меньше всех)") + } + if res.Winner != 1 { + t.Errorf("ожидался победитель 1, получено %d", res.Winner) + } +} + +func TestDropOnlyAllowedBeforeDrawing(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) + g.CurrentPlayer = 0 + g.Phase = PhaseAction // уже взяли карту — дропать поздно + + if _, err := g.Drop(); err != ErrWrongPhase { + t.Errorf("ожидалась ошибка ErrWrongPhase, получено: %v", err) + } +} + +func TestDropZeroSumAcrossManyPlayers(t *testing.T) { + g := NewGameWithStakes([]string{"A", "B", "C", "D"}, -1, 100, 7, 2) + g.Players[0].Hand = []Card{c(Five, Clubs)} + g.Players[1].Hand = []Card{c(Three, Hearts)} + g.Players[2].Hand = []Card{c(Three, Spades)} // ничья с игроком 1 за самую низкую + g.Players[3].Hand = []Card{c(King, Diamonds)} + g.CurrentPlayer = 0 + g.Phase = PhaseDraw + + res, err := g.Drop() + if err != nil { + t.Fatalf("неожиданная ошибка дропа: %v", err) + } + sum := 0 + for _, d := range res.BalanceDeltas { + sum += d + } + if sum != 0 { + t.Errorf("сумма изменений капитала при дропе должна быть 0, получено %d (%v)", sum, res.BalanceDeltas) + } +} + func TestDeclareTonk(t *testing.T) { g := NewGameWithStakes([]string{"A", "B"}, -1, 100, 10, 2) // банк = 20 (2 игрока * 10), капитал каждого сейчас 90 diff --git a/go.mod b/go.mod index 8132858..d975579 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ replace golang.org/x/sync => github.com/golang/sync v0.9.0 require ( github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 + github.com/muesli/termenv v0.15.2 ) require ( @@ -24,7 +25,6 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.27.0 // indirect diff --git a/go_bot.go b/go_bot.go new file mode 100644 index 0000000..b600249 --- /dev/null +++ b/go_bot.go @@ -0,0 +1,189 @@ +package main + +import "math/rand" + +// ПРИМЕЧАНИЕ О СИЛЕ ИГРЫ: сильный бот для Го в реальности требует +// Monte-Carlo поиска по дереву или нейросетевой оценки позиции +// (как в AlphaGo) — минимакс тут почти бесполезен из-за огромного +// ветвления (до нескольких сотен ходов на 19x19). Все три уровня +// ниже — эвристические, БЕЗ поиска вперёд по дереву партии, и +// играют существенно слабее сильного человека даже на "Сильном" +// уровне. Это осознанное ограничение, а не недоработка — честная +// сильная игра в Го выходит далеко за рамки того, что можно +// реализовать без полноценного MCTS/нейросети. + +// GoDifficulty — уровень сложности бота. +type GoDifficulty int + +const ( + GoDifficultyEasy GoDifficulty = iota + GoDifficultyMedium + GoDifficultyHard +) + +// GoBot — бот на основе эвристик без поиска вперёд. +type GoBot struct { + Difficulty GoDifficulty +} + +// goPassScoreThreshold — если у бота нет ни одного хода лучше этого +// эвристического порога, он пасует, а не портит позицию заведомо +// плохим ходом (актуально в основном под конец партии). +const goPassScoreThreshold = -5.0 + +// goEasyPassChance — простой бот не считает выгоду ходов, но всё же +// изредка пасует сам, иначе полностью случайная игра почти никогда +// не заканчивается естественным образом (лишние ходы почти всегда +// находятся, если не пасовать вообще никогда). +const goEasyPassChance = 0.04 + +// DecideMove выбирает ход бота. Возвращает (pos, true) для хода в +// точку pos, либо (_, false), если бот решил пасовать. +func (b GoBot) DecideMove(g *GoGameState, rnd *rand.Rand) (GoPos, bool) { + legal := goLegalMoves(g) + if len(legal) == 0 { + return GoPos{}, false + } + + switch b.Difficulty { + case GoDifficultyEasy: + if rnd.Float64() < goEasyPassChance { + return GoPos{}, false + } + return legal[rnd.Intn(len(legal))], true + default: + best := legal[0] + bestScore := goEvaluateMove(g, best, b.Difficulty) + for _, pos := range legal[1:] { + score := goEvaluateMove(g, pos, b.Difficulty) + if score > bestScore { + bestScore = score + best = pos + } + } + // при плотно заполненной доске бот больше не тратит ходы на + // бессмысленное заполнение нейтральных точек ("дамэ"), если + // лучший доступный ход не связан с реальным взятием (взятие + // всегда даёт +10 к оценке за камень, так что порог 8 + // надёжно отличает "взял что-то" от "просто занял свободную + // точку") — иначе партии ботов друг с другом никогда не + // заканчивались бы сами по себе + if goBoardOccupancy(g) > 0.5 && bestScore < 8 { + return GoPos{}, false + } + if bestScore < goPassScoreThreshold { + return GoPos{}, false + } + return best, true + } +} + +// goBoardOccupancy возвращает долю занятых точек доски (0..1). +func goBoardOccupancy(g *GoGameState) float64 { + occupied := 0 + for _, row := range g.Board { + for _, c := range row { + if c != GoEmpty { + occupied++ + } + } + } + return float64(occupied) / float64(g.Size*g.Size) +} + +// PlayFullTurn исполняет один ход бота (игру в точку либо пас). +func (b GoBot) PlayFullTurn(g *GoGameState, rnd *rand.Rand) error { + pos, ok := b.DecideMove(g, rnd) + if !ok { + return g.Pass() + } + return g.Play(pos) +} + +// goLegalMoves перечисляет все легальные точки для текущего игрока. +func goLegalMoves(g *GoGameState) []GoPos { + var out []GoPos + for r := 0; r < g.Size; r++ { + for c := 0; c < g.Size; c++ { + pos := GoPos{Row: r, Col: c} + if g.IsLegal(pos) { + out = append(out, pos) + } + } + } + return out +} + +// goEvaluateMove — эвристическая оценка одного кандидатского хода +// (без поиска вперёд): чем выше, тем привлекательнее ход для +// текущего игрока. +func goEvaluateMove(g *GoGameState, pos GoPos, level GoDifficulty) float64 { + color := g.Turn + sim := &GoGameState{ + Size: g.Size, + Board: cloneGoBoard(g.Board), + Turn: g.Turn, + prevBoardBeforeOpponentMove: g.prevBoardBeforeOpponentMove, + } + capturedBefore := boardStoneCount(sim.Board, color.Opponent()) + if err := sim.Play(pos); err != nil { + return -1000 + } + capturedAfter := boardStoneCount(sim.Board, color.Opponent()) + captures := capturedBefore - capturedAfter + + score := float64(captures) * 10 + + _, liberties := sim.groupAndLiberties(pos) + score += float64(liberties) + + if level == GoDifficultyHard { + score += goDistanceToCenterBonus(pos, g.Size) + score += goFriendlyNeighborBonus(g, pos) + } + + return score +} + +func boardStoneCount(board [][]GoColor, color GoColor) int { + count := 0 + for _, row := range board { + for _, c := range row { + if c == color { + count++ + } + } + } + return count +} + +// goDistanceToCenterBonus — небольшой позиционный бонус за игру +// ближе к центру доски в дебюте (грубая, но стандартная для +// простых Го-ботов эвристика). +func goDistanceToCenterBonus(pos GoPos, size int) float64 { + center := float64(size-1) / 2 + dr := float64(pos.Row) - center + dc := float64(pos.Col) - center + dist := dr*dr + dc*dc + maxDist := center * center * 2 + if maxDist == 0 { + return 0 + } + return 3 * (1 - dist/maxDist) +} + +// goFriendlyNeighborBonus поощряет игру рядом со своими же камнями +// (грубая замена полноценной оценке влияния/территории). +func goFriendlyNeighborBonus(g *GoGameState, pos GoPos) float64 { + color := g.Turn + bonus := 0.0 + for _, n := range goNeighbors(pos) { + if !g.inBounds(n) { + continue + } + if g.Board[n.Row][n.Col] == color { + bonus += 1.5 + } + } + return bonus +} diff --git a/go_bot_test.go b/go_bot_test.go new file mode 100644 index 0000000..56eb5c7 --- /dev/null +++ b/go_bot_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "math/rand" + "testing" +) + +// goRandomLegalMove находит случайную легальную точку для текущего +// игрока (или сигнализирует, что стоит пасовать, если ничего не +// нашлось за разумное число попыток). +func goRandomLegalMove(g *GoGameState, rnd *rand.Rand) (GoPos, bool) { + size := g.Size + order := rnd.Perm(size * size) + for _, idx := range order { + pos := GoPos{Row: idx / size, Col: idx % size} + if g.IsLegal(pos) { + return pos, true + } + } + return GoPos{}, false +} + +func TestGoRandomGamesNoCorruption(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + + for _, size := range []int{9, 13, 19} { + for game := 0; game < 10; game++ { + g := NewGoGame(size) + const maxMoves = 500 + moves := 0 + for ; moves < maxMoves && g.Result == nil; moves++ { + pos, ok := goRandomLegalMove(g, rnd) + if !ok { + if err := g.Pass(); err != nil { + t.Fatalf("размер %d, игра %d: неожиданная ошибка паса: %v", size, game, err) + } + continue + } + if err := g.Play(pos); err != nil { + t.Fatalf("размер %d, игра %d, ход %d: неожиданная ошибка легального хода %v: %v", + size, game, moves, pos, err) + } + } + if g.Result == nil { + g.Pass() + g.Pass() + } + if g.Result == nil { + t.Fatalf("размер %d, игра %d: партия так и не завершилась", size, game) + } + if g.Result.BlackScore < 0 || g.Result.WhiteScore < GoKomi { + t.Errorf("размер %d, игра %d: подозрительный счёт %+v", size, game, g.Result) + } + } + } +} + +func TestGoLegalMoveEnumerationPerformance19x19(t *testing.T) { + g := NewGoGame(19) + rnd := rand.New(rand.NewSource(2)) + for i := 0; i < 60; i++ { + pos, ok := goRandomLegalMove(g, rnd) + if !ok { + break + } + if err := g.Play(pos); err != nil { + t.Fatalf("шаг %d: неожиданная ошибка: %v", i, err) + } + } +} + +// TestGoBotFullGames прогоняет несколько полных партий бота против +// самого себя на каждом размере доски и уровне сложности, проверяя +// отсутствие паник, зависаний и корректность итогового результата. +func TestGoBotFullGames(t *testing.T) { + rnd := rand.New(rand.NewSource(3)) + + for _, size := range []int{9, 13, 19} { + for _, diff := range []GoDifficulty{GoDifficultyEasy, GoDifficultyMedium, GoDifficultyHard} { + bot := GoBot{Difficulty: diff} + g := NewGoGame(size) + + const maxMoves = 800 + moves := 0 + for ; moves < maxMoves && g.Result == nil; moves++ { + if err := bot.PlayFullTurn(g, rnd); err != nil { + t.Fatalf("размер %d, сложность %d, ход %d: неожиданная ошибка: %v", size, diff, moves, err) + } + } + if g.Result == nil { + t.Fatalf("размер %d, сложность %d: партия не завершилась за %d ходов", size, diff, maxMoves) + } + if g.Result.WhiteScore < GoKomi { + t.Errorf("размер %d, сложность %d: подозрительный счёт белых %v (меньше коми)", size, diff, g.Result.WhiteScore) + } + } + } +} diff --git a/go_game.go b/go_game.go new file mode 100644 index 0000000..6319df6 --- /dev/null +++ b/go_game.go @@ -0,0 +1,327 @@ +package main + +import "errors" + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: реализованы базовые правила Го — свободы +// групп камней, взятие групп без свобод, запрет самоубийственного +// хода (кроме случая, когда ход сам берёт вражеские камни и тем +// самым получает свободу), простое правило ко (нельзя немедленно +// воссоздать позицию, существовавшую до хода соперника). Подсчёт +// очков — по КИТАЙСКИМ (площадным) правилам: очки = количество +// своих камней на доске + количество пустых точек, граничащих +// ИСКЛЮЧИТЕЛЬНО с этим цветом (нейтральные точки, граничащие с +// обоими цветами, не засчитываются никому). Белые получают коми +// (компенсацию за то, что чёрные ходят первыми) — 7.5 очка на +// любом размере доски (в реальности коми иногда варьируется в +// зависимости от размера доски и разновидности правил — здесь для +// простоты используется одно фиксированное значение). +// +// СУЩЕСТВЕННОЕ УПРОЩЕНИЕ: фаза "удаления мёртвых камней" перед +// подсчётом очков НЕ реализована. После двух пасов подряд партия +// сразу же оценивается по камням, буквально оставшимся на доске — +// если камень технически окружён, но не взят взаправду, он +// засчитывается как живой своему цвету. Это значит, что для +// корректного счёта игроки должны реально ВЗЯТЬ мёртвые камни +// соперника до двух пасов, а не полагаться на то, что они "и так +// мертвы". Тройное повторение позиции (супер-ко) не реализовано — +// только простое ко (одна запрещённая точка на один ход вперёд). + +// GoColor — цвет камня. GoEmpty используется как значение пустой +// точки на доске. +type GoColor int + +const ( + GoEmpty GoColor = iota + GoBlack + GoWhite +) + +func (c GoColor) Opponent() GoColor { + switch c { + case GoBlack: + return GoWhite + case GoWhite: + return GoBlack + } + return GoEmpty +} + +// GoPos — координаты точки пересечения (0-based, row/col). +type GoPos struct { + Row, Col int +} + +// GoKomi — компенсация очков белым за то, что чёрные ходят первыми. +const GoKomi = 7.5 + +var ( + ErrGoOutOfBounds = errors.New("точка вне пределов доски") + ErrGoOccupied = errors.New("точка уже занята") + ErrGoSuicide = errors.New("этот ход самоубийственный — так ходить нельзя") + ErrGoKoViolation = errors.New("этот ход запрещён правилом ко") + ErrGoGameOver = errors.New("партия уже завершена") +) + +// GoResult — итог завершившейся партии. +type GoResult struct { + BlackScore float64 + WhiteScore float64 + Winner GoColor +} + +// GoGameState — полное состояние партии в Го. +type GoGameState struct { + Size int + Board [][]GoColor // Board[row][col] + Turn GoColor + + Passes int // подряд идущих пасов (0, 1 или 2 — на 2 партия завершается) + + CapturedByBlack int // сколько камней взяли чёрные (для отображения, не влияет на площадной счёт) + CapturedByWhite int + + prevBoardBeforeOpponentMove [][]GoColor // снимок доски до хода соперника — для правила ко + + Result *GoResult +} + +// NewGoGame создаёт новую пустую партию на доске size x size (обычно +// 9, 13 или 19). Первый ход — за чёрными. +func NewGoGame(size int) *GoGameState { + board := make([][]GoColor, size) + for r := range board { + board[r] = make([]GoColor, size) + } + return &GoGameState{Size: size, Board: board, Turn: GoBlack} +} + +func (g *GoGameState) inBounds(p GoPos) bool { + return p.Row >= 0 && p.Row < g.Size && p.Col >= 0 && p.Col < g.Size +} + +func goNeighbors(p GoPos) [4]GoPos { + return [4]GoPos{{p.Row - 1, p.Col}, {p.Row + 1, p.Col}, {p.Row, p.Col - 1}, {p.Row, p.Col + 1}} +} + +func cloneGoBoard(b [][]GoColor) [][]GoColor { + clone := make([][]GoColor, len(b)) + for i, row := range b { + clone[i] = append([]GoColor{}, row...) + } + return clone +} + +func boardsEqual(a, b [][]GoColor) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if len(a) != len(b) { + return false + } + for i := range a { + if len(a[i]) != len(b[i]) { + return false + } + for j := range a[i] { + if a[i][j] != b[i][j] { + return false + } + } + } + return true +} + +// groupAndLiberties возвращает все точки связной группы одного +// цвета, содержащей pos, и число различных пустых точек-свобод, +// граничащих с этой группой. +func (g *GoGameState) groupAndLiberties(pos GoPos) ([]GoPos, int) { + color := g.Board[pos.Row][pos.Col] + visited := map[GoPos]bool{pos: true} + liberties := map[GoPos]bool{} + queue := []GoPos{pos} + group := []GoPos{pos} + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, n := range goNeighbors(cur) { + if !g.inBounds(n) { + continue + } + switch g.Board[n.Row][n.Col] { + case GoEmpty: + liberties[n] = true + case color: + if !visited[n] { + visited[n] = true + group = append(group, n) + queue = append(queue, n) + } + } + } + } + return group, len(liberties) +} + +// removeGroup убирает с доски все камни группы (взятие). +func (g *GoGameState) removeGroup(group []GoPos) { + for _, p := range group { + g.Board[p.Row][p.Col] = GoEmpty + } +} + +// Play делает ход текущего игрока в точку pos. +func (g *GoGameState) Play(pos GoPos) error { + if g.Result != nil { + return ErrGoGameOver + } + if !g.inBounds(pos) { + return ErrGoOutOfBounds + } + if g.Board[pos.Row][pos.Col] != GoEmpty { + return ErrGoOccupied + } + + color := g.Turn + boardBeforeThisMove := cloneGoBoard(g.Board) + + g.Board[pos.Row][pos.Col] = color + captured := 0 + for _, n := range goNeighbors(pos) { + if !g.inBounds(n) || g.Board[n.Row][n.Col] != color.Opponent() { + continue + } + group, libs := g.groupAndLiberties(n) + if libs == 0 { + g.removeGroup(group) + captured += len(group) + } + } + + _, ownLiberties := g.groupAndLiberties(pos) + if ownLiberties == 0 { + g.Board = boardBeforeThisMove // откат: самоубийственный ход + return ErrGoSuicide + } + + if boardsEqual(g.Board, g.prevBoardBeforeOpponentMove) { + g.Board = boardBeforeThisMove // откат: нарушение правила ко + return ErrGoKoViolation + } + + if color == GoBlack { + g.CapturedByBlack += captured + } else { + g.CapturedByWhite += captured + } + + g.prevBoardBeforeOpponentMove = boardBeforeThisMove + g.Passes = 0 + g.Turn = color.Opponent() + return nil +} + +// Pass — текущий игрок пасует. Два паса подряд завершают партию. +func (g *GoGameState) Pass() error { + if g.Result != nil { + return ErrGoGameOver + } + g.Passes++ + g.prevBoardBeforeOpponentMove = cloneGoBoard(g.Board) + g.Turn = g.Turn.Opponent() + if g.Passes >= 2 { + g.finishGame() + } + return nil +} + +// IsLegal проверяет, допустим ли ход в pos для текущего игрока, не +// изменяя реальное состояние партии (использует пробный ход на +// копии доски). +func (g *GoGameState) IsLegal(pos GoPos) bool { + if g.Result != nil || !g.inBounds(pos) || g.Board[pos.Row][pos.Col] != GoEmpty { + return false + } + sim := &GoGameState{ + Size: g.Size, + Board: cloneGoBoard(g.Board), + Turn: g.Turn, + prevBoardBeforeOpponentMove: g.prevBoardBeforeOpponentMove, + } + return sim.Play(pos) == nil +} + +// finishGame считает итоговый счёт по площадным (китайским) правилам +// и определяет победителя. +func (g *GoGameState) finishGame() { + blackScore, whiteScore := 0, 0 + for r := 0; r < g.Size; r++ { + for c := 0; c < g.Size; c++ { + switch g.Board[r][c] { + case GoBlack: + blackScore++ + case GoWhite: + whiteScore++ + } + } + } + + visited := make([][]bool, g.Size) + for i := range visited { + visited[i] = make([]bool, g.Size) + } + for r := 0; r < g.Size; r++ { + for c := 0; c < g.Size; c++ { + if visited[r][c] || g.Board[r][c] != GoEmpty { + continue + } + region, touchesBlack, touchesWhite := g.floodEmptyRegion(GoPos{r, c}, visited) + switch { + case touchesBlack && !touchesWhite: + blackScore += len(region) + case touchesWhite && !touchesBlack: + whiteScore += len(region) + } + } + } + + result := &GoResult{BlackScore: float64(blackScore), WhiteScore: float64(whiteScore) + GoKomi} + if result.BlackScore > result.WhiteScore { + result.Winner = GoBlack + } else { + result.Winner = GoWhite + } + g.Result = result +} + +// floodEmptyRegion обходит связную область пустых точек, начиная с +// start, и определяет, граничит ли она с чёрными и/или белыми +// камнями. +func (g *GoGameState) floodEmptyRegion(start GoPos, visited [][]bool) (region []GoPos, touchesBlack, touchesWhite bool) { + queue := []GoPos{start} + visited[start.Row][start.Col] = true + region = append(region, start) + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, n := range goNeighbors(cur) { + if !g.inBounds(n) { + continue + } + switch g.Board[n.Row][n.Col] { + case GoEmpty: + if !visited[n.Row][n.Col] { + visited[n.Row][n.Col] = true + region = append(region, n) + queue = append(queue, n) + } + case GoBlack: + touchesBlack = true + case GoWhite: + touchesWhite = true + } + } + } + return region, touchesBlack, touchesWhite +} diff --git a/go_game_test.go b/go_game_test.go new file mode 100644 index 0000000..14ada44 --- /dev/null +++ b/go_game_test.go @@ -0,0 +1,317 @@ +package main + +import "testing" + +func gp(r, c int) GoPos { return GoPos{Row: r, Col: c} } + +func TestNewGoGameEmpty(t *testing.T) { + g := NewGoGame(9) + if g.Size != 9 { + t.Fatalf("ожидался размер доски 9, получено %d", g.Size) + } + if g.Turn != GoBlack { + t.Errorf("первый ход должен быть за чёрными") + } + for r := 0; r < 9; r++ { + for c := 0; c < 9; c++ { + if g.Board[r][c] != GoEmpty { + t.Fatalf("новая доска должна быть полностью пустой") + } + } + } +} + +func TestPlaySwitchesTurn(t *testing.T) { + g := NewGoGame(9) + if err := g.Play(gp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Board[4][4] != GoBlack { + t.Errorf("камень должен был встать в (4,4) как чёрный") + } + if g.Turn != GoWhite { + t.Errorf("ход должен был перейти к белым") + } +} + +func TestCannotPlayOnOccupiedPoint(t *testing.T) { + g := NewGoGame(9) + g.Play(gp(4, 4)) + if err := g.Play(gp(4, 4)); err != ErrGoOccupied { + t.Errorf("ожидалась ошибка занятой точки, получено: %v", err) + } +} + +func TestCannotPlayOutOfBounds(t *testing.T) { + g := NewGoGame(9) + if err := g.Play(gp(-1, 0)); err != ErrGoOutOfBounds { + t.Errorf("ожидалась ошибка выхода за границы, получено: %v", err) + } + if err := g.Play(gp(9, 0)); err != ErrGoOutOfBounds { + t.Errorf("ожидалась ошибка выхода за границы, получено: %v", err) + } +} + +func TestSingleStoneCapture(t *testing.T) { + g := NewGoGame(9) + g.Board[3][4] = GoBlack + g.Board[5][4] = GoBlack + g.Board[4][3] = GoBlack + g.Board[4][4] = GoWhite + g.Turn = GoBlack + + if err := g.Play(gp(4, 5)); err != nil { + t.Fatalf("неожиданная ошибка взятия: %v", err) + } + if g.Board[4][4] != GoEmpty { + t.Errorf("белый камень должен был быть взят") + } + if g.CapturedByBlack != 1 { + t.Errorf("ожидалось 1 взятие у чёрных, получено %d", g.CapturedByBlack) + } +} + +func TestGroupCaptureRemovesWholeGroup(t *testing.T) { + g := NewGoGame(9) + g.Board[4][4] = GoWhite + g.Board[4][5] = GoWhite + g.Board[3][4] = GoBlack + g.Board[3][5] = GoBlack + g.Board[5][4] = GoBlack + g.Board[5][5] = GoBlack + g.Board[4][3] = GoBlack + g.Turn = GoBlack + + if err := g.Play(gp(4, 6)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Board[4][4] != GoEmpty || g.Board[4][5] != GoEmpty { + t.Errorf("вся группа должна была быть взята целиком") + } + if g.CapturedByBlack != 2 { + t.Errorf("ожидалось 2 взятия, получено %d", g.CapturedByBlack) + } +} + +func TestSuicideMoveIsIllegal(t *testing.T) { + g := NewGoGame(9) + g.Board[3][4] = GoWhite + g.Board[5][4] = GoWhite + g.Board[4][3] = GoWhite + g.Board[4][5] = GoWhite + g.Turn = GoBlack + + if err := g.Play(gp(4, 4)); err != ErrGoSuicide { + t.Errorf("ожидалась ошибка самоубийственного хода, получено: %v", err) + } + if g.Board[4][4] != GoEmpty { + t.Errorf("доска не должна была измениться после отклонённого хода") + } +} + +func TestSuicideAllowedIfItCaptures(t *testing.T) { + g := NewGoGame(9) + // белый камень (4,4) окружён чёрными с трёх сторон, единственная + // свобода — (4,5); чёрные играют туда — это взятие, а не + // самоубийство их собственного камня + g.Board[3][4] = GoBlack + g.Board[5][4] = GoBlack + g.Board[4][3] = GoBlack + g.Board[4][4] = GoWhite + g.Turn = GoBlack + + if err := g.Play(gp(4, 5)); err != nil { + t.Fatalf("ход должен был пройти как взятие, а не самоубийство: %v", err) + } + if g.Board[4][4] != GoEmpty { + t.Errorf("белый камень должен был быть взят") + } + if g.Board[4][5] != GoBlack { + t.Errorf("чёрный камень должен был остаться на месте после взятия") + } +} + +func TestKoRuleForbidsImmediateRecapture(t *testing.T) { + g := NewGoGame(9) + // классическая форма ко (координаты row,col): + // (0,1)=W (0,2)=B + // (1,0)=W (1,1)=. (1,2)=W (1,3)=B <- белые только что взяли + // одинокого чёрного в (1,1), + // сыграв в (1,2) + // (2,1)=W (2,2)=B + // белый камень (1,2) сам стоит всего с одной свободой — (1,1) — + // и чёрные не могут немедленно отыграть его обратно + g.Board[0][1] = GoWhite + g.Board[0][2] = GoBlack + g.Board[1][0] = GoWhite + g.Board[1][2] = GoWhite + g.Board[1][3] = GoBlack + g.Board[2][1] = GoWhite + g.Board[2][2] = GoBlack + + // позиция ДО хода белых: на месте (1,2) было пусто, а на (1,1) + // стоял тот самый одинокий чёрный камень, которого взяли + before := cloneGoBoard(g.Board) + before[1][1] = GoBlack + before[1][2] = GoEmpty + g.prevBoardBeforeOpponentMove = before + g.Turn = GoBlack + + if err := g.Play(gp(1, 1)); err != ErrGoKoViolation { + t.Errorf("ожидалась ошибка нарушения ко, получено: %v", err) + } +} + +func TestKoAllowedAfterPlayingElsewhere(t *testing.T) { + g := NewGoGame(9) + g.Board[0][1] = GoWhite + g.Board[0][2] = GoBlack + g.Board[1][0] = GoWhite + g.Board[1][2] = GoWhite + g.Board[1][3] = GoBlack + g.Board[2][1] = GoWhite + g.Board[2][2] = GoBlack + before := cloneGoBoard(g.Board) + before[1][1] = GoBlack + before[1][2] = GoEmpty + g.prevBoardBeforeOpponentMove = before + g.Turn = GoBlack + + // чёрные играют "ходом ко" в другое место + if err := g.Play(gp(7, 7)); err != nil { + t.Fatalf("неожиданная ошибка хода в сторону: %v", err) + } + // теперь белые тоже играют куда-то ещё + if err := g.Play(gp(0, 7)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + // а теперь чёрные вновь могут взять в (1,1), т.к. позиция уже не та + if err := g.Play(gp(1, 1)); err != nil { + t.Fatalf("после ходов в сторону взятие должно быть разрешено: %v", err) + } +} + +func TestPassTwiceEndsGame(t *testing.T) { + g := NewGoGame(9) + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result != nil { + t.Fatalf("после одного паса партия не должна завершаться") + } + if err := g.Pass(); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil { + t.Fatalf("после двух пасов подряд партия должна была завершиться") + } +} + +func TestPassResetByPlay(t *testing.T) { + g := NewGoGame(9) + g.Pass() + if err := g.Play(gp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Passes != 0 { + t.Errorf("счётчик пасов должен был сброситься после реального хода") + } +} + +func TestScoringEmptyBoardKomiDecides(t *testing.T) { + g := NewGoGame(9) + g.Pass() + g.Pass() + if g.Result == nil { + t.Fatalf("партия должна была завершиться") + } + if g.Result.BlackScore != 0 { + t.Errorf("на пустой доске у чёрных должно быть 0 очков, получено %v", g.Result.BlackScore) + } + if g.Result.WhiteScore != GoKomi { + t.Errorf("у белых должно быть ровно коми (%v), получено %v", GoKomi, g.Result.WhiteScore) + } + if g.Result.Winner != GoWhite { + t.Errorf("на пустой доске белые должны побеждать за счёт коми") + } +} + +func TestScoringCountsStonesAndTerritory(t *testing.T) { + g := NewGoGame(9) + // угловая точка (0,0) на доске имеет всего два соседа — (0,1) и + // (1,0); если оба чёрные, то сама точка (0,0), оставшись пустой, + // полностью замкнута исключительно чёрным цветом + g.Board[0][1] = GoBlack + g.Board[1][0] = GoBlack + g.Board[8][8] = GoWhite + + g.Pass() + g.Pass() + + if g.Result == nil { + t.Fatalf("партия должна была завершиться") + } + // у чёрных: 2 камня + 1 замкнутая пустая точка (0,0) = 3 + if g.Result.BlackScore != 3 { + t.Errorf("ожидалось 3 очка у чёрных, получено %v", g.Result.BlackScore) + } +} + +func TestNeutralTerritoryNotScoredToEither(t *testing.T) { + g := NewGoGame(9) + g.Board[4][4] = GoBlack + g.Board[4][5] = GoWhite + + g.Pass() + g.Pass() + + if g.Result == nil { + t.Fatalf("партия должна была завершиться") + } + if g.Result.BlackScore != 1 { + t.Errorf("ожидалось 1 очко у чёрных (только свой камень), получено %v", g.Result.BlackScore) + } + if g.Result.WhiteScore != 1+GoKomi { + t.Errorf("ожидалось 1+коми у белых, получено %v", g.Result.WhiteScore) + } +} + +func TestIsLegalDoesNotMutateState(t *testing.T) { + g := NewGoGame(9) + g.Board[3][4] = GoWhite + g.Board[5][4] = GoWhite + g.Board[4][3] = GoWhite + g.Board[4][5] = GoWhite + g.Turn = GoBlack + + if g.IsLegal(gp(4, 4)) { + t.Errorf("самоубийственный ход не должен считаться легальным") + } + if g.Board[4][4] != GoEmpty { + t.Errorf("IsLegal не должен был изменить реальную доску") + } + if g.Turn != GoBlack { + t.Errorf("IsLegal не должен был менять очередь хода") + } +} + +func TestPlayAfterGameOverReturnsError(t *testing.T) { + g := NewGoGame(9) + g.Pass() + g.Pass() + if err := g.Play(gp(0, 0)); err != ErrGoGameOver { + t.Errorf("ожидалась ErrGoGameOver, получено: %v", err) + } + if err := g.Pass(); err != ErrGoGameOver { + t.Errorf("ожидалась ErrGoGameOver при пасе после конца игры, получено: %v", err) + } +} + +func TestBoardSizesSupported(t *testing.T) { + for _, size := range []int{9, 13, 19} { + g := NewGoGame(size) + if g.Size != size || len(g.Board) != size || len(g.Board[0]) != size { + t.Errorf("некорректная доска для размера %d", size) + } + } +} diff --git a/go_tui.go b/go_tui.go new file mode 100644 index 0000000..8eb2314 --- /dev/null +++ b/go_tui.go @@ -0,0 +1,250 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// goColumnLetters — буквы столбцов доски Го: традиционно пропускают +// "I", чтобы не путать с "1". +const goColumnLetters = "ABCDEFGHJKLMNOPQRST" + +// GoModel — TUI поверх GoGameState. Человек всегда играет чёрными +// (ходят первыми), бот — белыми. +type GoModel struct { + game *GoGameState + bot GoBot + rnd *rand.Rand + human GoColor + cursor GoPos + size int + difficulty GoDifficulty + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewGoModel создаёт новую партию на доске size x size с ботом +// уровня difficulty. Человек играет чёрными. +func NewGoModel(size int, difficulty GoDifficulty) GoModel { + return GoModel{ + game: NewGoGame(size), + bot: GoBot{Difficulty: difficulty}, + rnd: rand.New(rand.NewSource(time.Now().UnixNano())), + human: GoBlack, + cursor: GoPos{Row: size / 2, Col: size / 2}, + size: size, + difficulty: difficulty, + } +} + +func (m GoModel) Init() tea.Cmd { + return m.maybeScheduleBot() +} + +func (m GoModel) maybeScheduleBot() tea.Cmd { + if m.game.Result != nil { + return nil + } + if m.game.Turn == m.human { + return nil + } + return tea.Tick(botMoveDelay, func(time.Time) tea.Msg { return botMoveMsg{} }) +} + +func (m *GoModel) setInfo(key string) { + m.message = T(key) + m.isError = false +} + +func (m *GoModel) setError(err error) { + m.message = T(err.Error()) + m.isError = true +} + +func (m GoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + case botMoveMsg: + return m.handleBotMove() + } + return m, nil +} + +func (m GoModel) handleBotMove() (tea.Model, tea.Cmd) { + if m.game.Result != nil || m.game.Turn == m.human { + return m, nil + } + if err := m.bot.PlayFullTurn(m.game, m.rnd); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("go.msg.bot_moved") + return m, m.maybeScheduleBot() +} + +func (m GoModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + if key == "q" { + m.backToMenu = true + return m, nil + } + + if m.game.Result != nil { + if key == "n" { + m.game = NewGoGame(m.size) + m.cursor = GoPos{Row: m.size / 2, Col: m.size / 2} + m.message = "" + m.isError = false + return m, m.maybeScheduleBot() + } + return m, nil + } + if m.game.Turn != m.human { + return m, nil + } + + switch key { + case "up", "k": + if m.cursor.Row > 0 { + m.cursor.Row-- + } + case "down", "j": + if m.cursor.Row < m.game.Size-1 { + m.cursor.Row++ + } + case "left", "h": + if m.cursor.Col > 0 { + m.cursor.Col-- + } + case "right", "l": + if m.cursor.Col < m.game.Size-1 { + m.cursor.Col++ + } + case "enter", " ": + if err := m.game.Play(m.cursor); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("go.msg.played") + return m, m.maybeScheduleBot() + case "p": + if err := m.game.Pass(); err != nil { + m.setError(err) + return m, nil + } + m.setInfo("go.msg.passed") + return m, m.maybeScheduleBot() + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +func goStoneGlyph(c GoColor) string { + switch c { + case GoBlack: + return checkersBlackPiece.Render("●") + case GoWhite: + return checkersWhitePiece.Render("○") + } + return dimStyle.Render("·") +} + +func goStoneSymbolPlain(c GoColor) string { + switch c { + case GoBlack: + return "●" + case GoWhite: + return "○" + } + return "·" +} + +func (m GoModel) renderBoard() string { + size := m.game.Size + var b strings.Builder + + fmt.Fprint(&b, " ") + for c := 0; c < size; c++ { + fmt.Fprintf(&b, "%c ", goColumnLetters[c]) + } + fmt.Fprintln(&b) + + for r := 0; r < size; r++ { + label := size - r + fmt.Fprintf(&b, "%2d ", label) + for c := 0; c < size; c++ { + pos := GoPos{Row: r, Col: c} + glyph := goStoneGlyph(m.game.Board[r][c]) + if pos == m.cursor && m.game.Result == nil { + glyph = cursorStyle.Render(goStoneSymbolPlain(m.game.Board[r][c])) + } + fmt.Fprint(&b, glyph+" ") + } + fmt.Fprintln(&b) + } + return b.String() +} + +func (m GoModel) View() string { + if m.quitting { + return T("common.goodbye") + } + + g := m.game + var b strings.Builder + + fmt.Fprintln(&b, titleStyle.Render(T("go.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("go.header.captures", g.CapturedByBlack, g.CapturedByWhite)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderBoard()) + fmt.Fprintln(&b) + + if g.Result != nil { + res := g.Result + winnerKey := "go.color.black" + if res.Winner == GoWhite { + winnerKey = "go.color.white" + } + fmt.Fprintln(&b, winStyle.Render(Tf("go.result.line", T(winnerKey), res.BlackScore, res.WhiteScore))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render(T("go.hint.gameover"))) + return b.String() + } + turnLabel := T("go.turn.you") + if g.Turn != m.human { + turnLabel = T("go.turn.bot") + } + fmt.Fprintln(&b, turnLabel) + fmt.Fprintln(&b) + + if g.Turn == m.human { + fmt.Fprintln(&b, T("go.hint.play")) + } + + fmt.Fprintln(&b) + if m.message != "" { + if m.isError { + fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) + } else { + fmt.Fprintln(&b, infoStyle.Render(m.message)) + } + } + + return b.String() +} diff --git a/go_tui_test.go b/go_tui_test.go new file mode 100644 index 0000000..a85ddd9 --- /dev/null +++ b/go_tui_test.go @@ -0,0 +1,139 @@ +package main + +import "testing" + +func TestGoModelPlayStoneViaKey(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + m.cursor = GoPos{Row: 4, Col: 4} + + next, _ := m.Update(key("enter")) + m = next.(GoModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if m.game.Board[4][4] != GoBlack { + t.Fatalf("камень должен был встать в (4,4) как чёрный") + } +} + +func TestGoModelCursorMovement(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + m.cursor = GoPos{Row: 4, Col: 4} + + next, _ := m.Update(key("up")) + m = next.(GoModel) + if m.cursor.Row != 3 { + t.Errorf("курсор должен был сдвинуться вверх") + } + next, _ = m.Update(key("right")) + m = next.(GoModel) + if m.cursor.Col != 5 { + t.Errorf("курсор должен был сдвинуться вправо") + } +} + +func TestGoModelCursorClampsAtEdges(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + m.cursor = GoPos{Row: 0, Col: 0} + + next, _ := m.Update(key("up")) + m = next.(GoModel) + if m.cursor.Row != 0 { + t.Errorf("курсор не должен был уйти за верхний край") + } + next, _ = m.Update(key("left")) + m = next.(GoModel) + if m.cursor.Col != 0 { + t.Errorf("курсор не должен был уйти за левый край") + } +} + +func TestGoModelPassViaKey(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + next, _ := m.Update(key("p")) + m = next.(GoModel) + if m.isError { + t.Fatalf("неожиданная ошибка паса: %s", m.message) + } +} + +func TestGoModelInvalidMoveShowsError(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + m.cursor = GoPos{Row: 0, Col: 0} + m.game.Board[0][0] = GoBlack + + next, _ := m.Update(key("enter")) + m = next.(GoModel) + if !m.isError { + t.Fatalf("ожидалась ошибка хода на занятую точку") + } +} + +func TestGoModelQReturnsToMenu(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + next, _ := m.Update(key("q")) + m = next.(GoModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestGoModelNewGameAfterResult(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + m.game.Result = &GoResult{Winner: GoBlack, BlackScore: 10, WhiteScore: 7.5} + + next, _ := m.Update(key("n")) + m = next.(GoModel) + if m.game.Result != nil { + t.Errorf("новая партия не должна была начаться с результатом") + } + if m.game.Size != 9 { + t.Errorf("новая партия должна была сохранить размер доски") + } +} + +func TestGoModelSupportsAllBoardSizes(t *testing.T) { + for _, size := range []int{9, 13, 19} { + m := NewGoModel(size, GoDifficultyMedium) + if m.game.Size != size { + t.Errorf("ожидался размер доски %d, получено %d", size, m.game.Size) + } + } +} + +// TestGoModelFullAutoPlaythrough прогоняет TUI-модель от начала до +// конца на маленькой доске, где "человек" отыгрывает свои ходы +// через бота, проверяя, что весь путь через Update не паникует и +// партия завершается. +func TestGoModelFullAutoPlaythrough(t *testing.T) { + m := NewGoModel(9, GoDifficultyEasy) + helperBot := GoBot{Difficulty: GoDifficultyEasy} + rnd := m.rnd + + const maxSteps = 3000 + for i := 0; i < maxSteps && m.game.Result == nil; i++ { + if m.game.Turn != m.human { + next, _ := m.Update(botMoveMsg{}) + m = next.(GoModel) + continue + } + pos, ok := helperBot.DecideMove(m.game, rnd) + if !ok { + next, _ := m.Update(key("p")) + m = next.(GoModel) + continue + } + m.cursor = pos + next, _ := m.Update(key("enter")) + m = next.(GoModel) + if m.isError { + t.Fatalf("неожиданная ошибка хода человеком: %s", m.message) + } + } + if m.game.Result == nil { + t.Fatalf("партия через TUI не завершилась за %d шагов", maxSteps) + } +} diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..00e0f90 --- /dev/null +++ b/i18n.go @@ -0,0 +1,56 @@ +package main + +import "fmt" + +// Lang — язык интерфейса. +type Lang int + +const ( + LangRU Lang = iota + LangEN +) + +// currentLang — текущий выбранный язык интерфейса. Пакетный +// уровень оправдан тем, что приложение однопоточное (TUI обновляется +// строго последовательно через Bubble Tea), а язык выбирается один +// раз в начале (см. экран выбора языка в menu.go) и меняется только +// через явное действие пользователя. +var currentLang = LangRU + +// SetLanguage устанавливает текущий язык интерфейса. +func SetLanguage(l Lang) { + currentLang = l +} + +// CurrentLang возвращает текущий язык интерфейса. +func CurrentLang() Lang { + return currentLang +} + +// translationEntry — пара переводов одного ключа. +type translationEntry struct { + ru string + en string +} + +// T возвращает перевод строки по ключу key на текущем языке. Если +// ключ не найден в таблице translations — возвращается сам ключ +// (это осознанный "мягкий" фолбэк: пропущенный перевод не ломает +// интерфейс, а просто виден как есть, что легко заметить и +// исправить). +func T(key string) string { + if e, ok := translations[key]; ok { + if CurrentLang() == LangEN { + return e.en + } + return e.ru + } + return key +} + +// Tf — то же самое, что T, но для строк-форматов (используются как +// первый аргумент fmt.Sprintf): сначала переводится сам формат, +// затем в него подставляются аргументы. +func Tf(key string, args ...interface{}) string { + return fmt.Sprintf(T(key), args...) +} diff --git a/klondike_tui.go b/klondike_tui.go index 66bec7c..2392e96 100644 --- a/klondike_tui.go +++ b/klondike_tui.go @@ -48,7 +48,7 @@ func (m *KlondikeModel) setInfo(format string, args ...interface{}) { } func (m *KlondikeModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -99,7 +99,7 @@ func (m KlondikeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.clearSelection() - m.setInfo("Ход отменён.") + m.setInfo(T("klondike.msg.undone")) return m, nil case "esc": m.clearSelection() @@ -111,7 +111,7 @@ func (m KlondikeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Взяли карту из колоды.") + m.setInfo(T("klondike.msg.drew")) return m, nil case "1", "2", "3", "4", "5", "6", "7": col := int(key[0] - '1') @@ -153,7 +153,7 @@ func (m KlondikeModel) handlePileKey(ref klondikePileRef) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Ход выполнен.") + m.setInfo(T("klondike.msg.moved")) m.clearSelection() return m, nil } @@ -177,7 +177,7 @@ func (m KlondikeModel) handleFoundationKey() (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Карта отправлена на фундамент.") + m.setInfo(T("klondike.msg.to_foundation")) m.clearSelection() return m, nil } @@ -208,7 +208,7 @@ func (m KlondikeModel) renderFoundations() string { func (m KlondikeModel) renderStockWaste() string { stockStr := dimStyle.Render(fmt.Sprintf("(%d)", len(m.game.Stock))) - wasteStr := dimStyle.Render("(пусто)") + wasteStr := dimStyle.Render(T("klondike.empty")) if len(m.game.Waste) > 0 { top := m.game.Waste[len(m.game.Waste)-1] wasteStr = renderCard(top) @@ -216,7 +216,7 @@ func (m KlondikeModel) renderStockWaste() string { wasteStr = cursorStyle.Render(top.String()) } } - return fmt.Sprintf("Колода: %s Отбой: %s", stockStr, wasteStr) + return fmt.Sprintf(T("klondike.status_line"), stockStr, wasteStr) } func (m KlondikeModel) renderTableau() string { @@ -267,31 +267,31 @@ func (m KlondikeModel) renderTableau() string { func (m KlondikeModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== КОСЫНКА ===")) + fmt.Fprintln(&b, titleStyle.Render(T("klondike.title"))) fmt.Fprintln(&b) - fmt.Fprintf(&b, "Фундаменты: %s\n", m.renderFoundations()) + fmt.Fprintf(&b, T("klondike.foundations_line"), m.renderFoundations()) fmt.Fprintln(&b, m.renderStockWaste()) - fmt.Fprintf(&b, "Ходов: %d\n\n", m.game.Moves) + fmt.Fprintf(&b, T("klondike.moves_line"), m.game.Moves) fmt.Fprintln(&b, m.renderTableau()) fmt.Fprintln(&b) if m.game.Won { - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Пасьянс сошёлся за %d ходов! Поздравляем!", m.game.Moves))) + fmt.Fprintln(&b, winStyle.Render(Tf("klondike.won", m.game.Moves))) fmt.Fprintln(&b) - fmt.Fprintln(&b, dimStyle.Render("[n] новая партия [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("klondike.hint.gameover"))) return b.String() } if m.selected == klondikeNoSelection { - fmt.Fprintln(&b, "[1-7] выбрать колонну [0] выбрать отбой [d] взять из колоды") + fmt.Fprintln(&b, T("klondike.hint.noselect")) } else { - fmt.Fprintln(&b, "[1-7] переложить в колонну [f] на фундамент [esc] отменить выбор [d] взять из колоды") + fmt.Fprintln(&b, T("klondike.hint.selected")) } - fmt.Fprintln(&b, dimStyle.Render("[u] отменить ход [n] новая партия [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("klondike.hint.footer"))) fmt.Fprintln(&b) if m.message != "" { diff --git a/main.go b/main.go index e335216..1b70621 100644 --- a/main.go +++ b/main.go @@ -9,9 +9,16 @@ import ( ) func main() { + loadWayfarerGalaxySeed() + if len(os.Args) > 1 { switch os.Args[1] { case "-h", "--help": + SetLanguage(LangRU) + printHelp() + return + case "--help-en": + SetLanguage(LangEN) printHelp() return case "-sim": @@ -40,29 +47,43 @@ func main() { } // printHelp выводит общее описание запуска и правила всех игр из -// реестра — доступно без захода в TUI, годится для чтения через -// `go-games-collection --help | less` или на непривычной к TUI -// системе. +// реестра на текущем языке (см. SetLanguage) — доступно без захода +// в TUI, годится для чтения через `go-games-collection --help | +// less` или на непривычной к TUI системе. func printHelp() { fmt.Println(asciiBanner) - fmt.Println("Коллекция консольных игр на Go") + fmt.Println(T("app.subtitle")) fmt.Println() - fmt.Println("ЗАПУСК") - fmt.Println(" go-games-collection открыть меню коллекции") - fmt.Println(" go-games-collection -sim headless-симуляция ботов Тонка (без TUI)") - fmt.Println(" go-games-collection -sim-durak headless-симуляция ботов Дурака (без TUI)") - fmt.Println(" go-games-collection -sim-blackjack headless-симуляция ботов Блэкджека (без TUI)") - fmt.Println(" go-games-collection -sim-101 headless-симуляция ботов 101 (без TUI)") - fmt.Println(" go-games-collection -sim-1000 headless-симуляция ботов 1000 (без TUI)") - fmt.Println(" go-games-collection --help показать эту справку и правила игр") + if CurrentLang() == LangEN { + fmt.Println("USAGE") + fmt.Println(" go-games-collection open the collection menu") + fmt.Println(" go-games-collection -sim headless Tonk bot simulation (no TUI)") + fmt.Println(" go-games-collection -sim-durak same, for Durak") + fmt.Println(" go-games-collection -sim-blackjack same, for Blackjack") + fmt.Println(" go-games-collection -sim-101 same, for 101") + fmt.Println(" go-games-collection -sim-1000 same, for 1000 (Thousand)") + fmt.Println(" go-games-collection --help show this help and rules in Russian") + fmt.Println(" go-games-collection --help-en show this help and rules in English") + } else { + fmt.Println("ЗАПУСК") + fmt.Println(" go-games-collection открыть меню коллекции") + fmt.Println(" go-games-collection -sim headless-симуляция ботов Тонка (без TUI)") + fmt.Println(" go-games-collection -sim-durak headless-симуляция ботов Дурака (без TUI)") + fmt.Println(" go-games-collection -sim-blackjack headless-симуляция ботов Блэкджека (без TUI)") + fmt.Println(" go-games-collection -sim-101 headless-симуляция ботов 101 (без TUI)") + fmt.Println(" go-games-collection -sim-1000 headless-симуляция ботов 1000 (без TUI)") + fmt.Println(" go-games-collection --help показать эту справку и правила на русском") + fmt.Println(" go-games-collection --help-en показать эту справку и правила на английском") + } fmt.Println() for _, g := range gameRegistry { - separator := strings.Repeat("=", len(g.Name)+4) + name := g.Name() + separator := strings.Repeat("=", len(name)+4) fmt.Println(separator) - fmt.Printf(" %s\n", g.Name) + fmt.Printf(" %s\n", name) fmt.Println(separator) fmt.Println() - fmt.Println(g.Rules) + fmt.Println(g.Rules()) } } diff --git a/menu.go b/menu.go index bd4472e..db2b6a2 100644 --- a/menu.go +++ b/menu.go @@ -12,9 +12,9 @@ import ( // чтобы добавление новой игры сводилось к добавлению одной записи // в gameRegistry, без изменений в логике меню. type GameEntry struct { - Name string - Description string - Rules string + Name func() string + Description func() string + Rules func() string New func() tea.Model // UsesStakes — включает экран настройки ставки перед запуском: @@ -41,29 +41,44 @@ type GameEntry struct { // бота (простой/сильный) перед началом партии. UsesCheckersSettings bool NewCheckersWithDifficulty func(depth int) tea.Model + + // UsesChessSettings — то же самое, но для шахмат (свой уровень + // глубины поиска, т.к. ветвление в шахматах намного шире). + UsesChessSettings bool + NewChessWithDifficulty func(depth int) tea.Model + + // UsesGoSettings — экран с двумя полями: размер доски (9/13/19) + // и уровень сложности бота. + UsesGoSettings bool + NewGoWithSettings func(size int, difficulty GoDifficulty) tea.Model + + // UsesMinesweeperSettings — экран выбора пресета размера поля + // (Новичок/Любитель/Эксперт). + UsesMinesweeperSettings bool + NewMinesweeperWithPreset func(width, height, mines int) tea.Model } // gameRegistry — список доступных игр коллекции. var gameRegistry = []GameEntry{ { - Name: "Тонк (Tonk)", - Description: "Карточная игра на 1 человека + 3 бота разного уровня сложности", + Name: func() string { return T("game.tonk.name") }, + Description: func() string { return T("game.tonk.desc") }, Rules: tonkRules, New: func() tea.Model { return NewModel() }, UsesStakes: true, NewWithStakes: func(capital, ante, mult int) tea.Model { return NewModelWithStakes(capital, ante, mult) }, }, { - Name: "Дурак (Durak)", - Description: "Классическая карточная игра на 2-6 игроков (подкидной вариант)", + Name: func() string { return T("game.durak.name") }, + Description: func() string { return T("game.durak.desc") }, Rules: durakRules, New: func() tea.Model { return NewDurakModel(4) }, UsesPlayerCount: true, NewWithPlayerCount: func(n int) tea.Model { return NewDurakModel(n) }, }, { - Name: "Блэкджек (Blackjack)", - Description: "Каждый играет против дилера, 1-6 игроков включая человека", + Name: func() string { return T("game.bj.name") }, + Description: func() string { return T("game.bj.desc") }, Rules: blackjackRules, UsesBlackjackSettings: true, NewBlackjackWithSettings: func(numPlayers, capital, bet int) tea.Model { @@ -71,46 +86,89 @@ var gameRegistry = []GameEntry{ }, }, { - Name: "101", - Description: "Избавьтесь от карт первым; штрафные очки за оставшиеся на руках, до 101 — выбывание", + Name: func() string { return T("game.101.name") }, + Description: func() string { return T("game.101.desc") }, Rules: oneOhOneRules, New: func() tea.Model { return NewOneOhOneModel(4) }, UsesPlayerCount: true, NewWithPlayerCount: func(n int) tea.Model { return NewOneOhOneModel(n) }, }, { - Name: "1000 (Тысяча)", - Description: "Взяточная игра с торгами и марьяжами, строго на троих (вы + 2 бота)", + Name: func() string { return T("game.1000.name") }, + Description: func() string { return T("game.1000.desc") }, Rules: thousandRules, New: func() tea.Model { return NewThousandModel() }, }, { - Name: "Косынка (Klondike)", - Description: "Классический пасьянс на одного игрока, без ботов", + Name: func() string { return T("game.klondike.name") }, + Description: func() string { return T("game.klondike.desc") }, Rules: klondikeRules, New: func() tea.Model { return NewKlondikeModel() }, }, { - Name: "Шашки (Checkers)", - Description: "Русские шашки против бота (минимакс), обязательное взятие", + Name: func() string { return T("game.checkers.name") }, + Description: func() string { return T("game.checkers.desc") }, Rules: checkersRules, UsesCheckersSettings: true, NewCheckersWithDifficulty: func(depth int) tea.Model { return NewCheckersModel(depth) }, }, + { + Name: func() string { return T("game.chess.name") }, + Description: func() string { return T("game.chess.desc") }, + Rules: chessRules, + UsesChessSettings: true, + NewChessWithDifficulty: func(depth int) tea.Model { + return NewChessModel(depth) + }, + }, + { + Name: func() string { return T("game.corpsestarch.name") }, + Description: func() string { return T("game.corpsestarch.desc") }, + Rules: corpseStarchRules, + New: func() tea.Model { return NewCorpseStarchModel() }, + }, + { + Name: func() string { return T("game.go.name") }, + Description: func() string { return T("game.go.desc") }, + Rules: goRules, + UsesGoSettings: true, + NewGoWithSettings: func(size int, difficulty GoDifficulty) tea.Model { + return NewGoModel(size, difficulty) + }, + }, + { + Name: func() string { return T("game.minesweeper.name") }, + Description: func() string { return T("game.minesweeper.desc") }, + Rules: minesweeperRules, + UsesMinesweeperSettings: true, + NewMinesweeperWithPreset: func(width, height, mines int) tea.Model { + return NewMinesweeperModel(width, height, mines) + }, + }, + { + Name: func() string { return T("game.wayfarer.name") }, + Description: func() string { return T("game.wayfarer.desc") }, + Rules: wayfarerRules, + New: func() tea.Model { return NewWayfarerModel() }, + }, } type appState int const ( - stateMenu appState = iota + stateLanguage appState = iota + stateMenu stateRulesMenu stateRules stateSettings statePlayerCount stateBlackjackSettings stateCheckersSettings + stateChessSettings + stateGoSettings + stateMinesweeperSettings statePlaying ) @@ -201,12 +259,12 @@ type checkersSettings struct { } var checkersLevels = []struct { - Label string - Depth int - Desc string + LabelKey string + Depth int + DescKey string }{ - {Label: "Простой", Depth: CheckersDifficultyEasy, Desc: "быстрый, ощутимо слабее"}, - {Label: "Сильный", Depth: CheckersDifficultyHard, Desc: "заметно сильнее, чуть медленнее думает"}, + {LabelKey: "checkerslvl.easy", Depth: CheckersDifficultyEasy, DescKey: "checkerslvl.easy_desc"}, + {LabelKey: "checkerslvl.hard", Depth: CheckersDifficultyHard, DescKey: "checkerslvl.hard_desc"}, } func newCheckersSettings(gameIdx int) checkersSettings { @@ -223,6 +281,116 @@ func (s *checkersSettings) cycle(delta int) { } } +// chessSettings — тот же самый экран выбора сложности, но для +// шахмат (свой уровень глубины поиска, т.к. ветвление в шахматах +// намного шире, чем в шашках). +type chessSettings struct { + levelIdx int + gameIdx int +} + +var chessLevels = []struct { + LabelKey string + Depth int + DescKey string +}{ + {LabelKey: "checkerslvl.easy", Depth: ChessDifficultyEasy, DescKey: "checkerslvl.easy_desc"}, + {LabelKey: "checkerslvl.hard", Depth: ChessDifficultyHard, DescKey: "checkerslvl.hard_desc"}, +} + +func newChessSettings(gameIdx int) chessSettings { + return chessSettings{levelIdx: 0, gameIdx: gameIdx} +} + +func (s *chessSettings) cycle(delta int) { + s.levelIdx += delta + if s.levelIdx < 0 { + s.levelIdx = 0 + } + if s.levelIdx > len(chessLevels)-1 { + s.levelIdx = len(chessLevels) - 1 + } +} + +// goSettings — экран настройки партии в Го: два поля (размер доски +// и уровень сложности бота). +type goSettings struct { + sizeOptions []int + sizeIdx int + difficultyIdx int + fieldCursor int // 0 = размер доски, 1 = сложность + gameIdx int +} + +var goDifficultyLevels = []struct { + LabelKey string + Level GoDifficulty +}{ + {LabelKey: "gosettings.level.easy", Level: GoDifficultyEasy}, + {LabelKey: "gosettings.level.medium", Level: GoDifficultyMedium}, + {LabelKey: "gosettings.level.hard", Level: GoDifficultyHard}, +} + +func newGoSettings(gameIdx int) goSettings { + return goSettings{ + sizeOptions: []int{9, 13, 19}, + sizeIdx: 0, + difficultyIdx: 0, + gameIdx: gameIdx, + } +} + +func (s goSettings) size() int { return s.sizeOptions[s.sizeIdx] } +func (s goSettings) difficulty() GoDifficulty { return goDifficultyLevels[s.difficultyIdx].Level } + +func (s *goSettings) cycle(delta int) { + clamp := func(idx, max int) int { + idx += delta + if idx < 0 { + return 0 + } + if idx > max { + return max + } + return idx + } + switch s.fieldCursor { + case 0: + s.sizeIdx = clamp(s.sizeIdx, len(s.sizeOptions)-1) + case 1: + s.difficultyIdx = clamp(s.difficultyIdx, len(goDifficultyLevels)-1) + } +} + +// minesweeperSettings — экран выбора пресета размера поля Сапёра. +type minesweeperSettings struct { + presetIdx int + gameIdx int +} + +var minesweeperPresets = []struct { + LabelKey string + Width, Height, Mines int +}{ + {"mssettings.beginner", 9, 9, 10}, + {"mssettings.intermediate", 16, 16, 40}, + {"mssettings.expert", 30, 16, 99}, +} + +func newMinesweeperSettings(gameIdx int) minesweeperSettings { + return minesweeperSettings{presetIdx: 0, gameIdx: gameIdx} +} + +func (s *minesweeperSettings) cycle(delta int) { + s.presetIdx += delta + if s.presetIdx < 0 { + s.presetIdx = 0 + } + if s.presetIdx > len(minesweeperPresets)-1 { + s.presetIdx = len(minesweeperPresets) - 1 + } +} + type blackjackSettings struct { capitalOptions []int betOptions []int @@ -287,39 +455,80 @@ var ( // menuOption — один пункт меню: либо запуск игры из реестра, либо // служебное действие (правила/выход). type menuOption struct { - label string - desc string kind string // "game", "rules", "quit" gameIdx int // индекс в gameRegistry, если kind == "game" } +// label возвращает актуальный (для текущего языка) текст пункта меню. +func (opt menuOption) label() string { + switch opt.kind { + case "game": + return gameRegistry[opt.gameIdx].Name() + case "rules": + return T("menu.rules") + case "language": + return T("menu.language") + case "quit": + return T("menu.quit") + } + return "" +} + +// desc возвращает актуальное (для текущего языка) описание пункта меню. +func (opt menuOption) desc() string { + switch opt.kind { + case "game": + return gameRegistry[opt.gameIdx].Description() + case "rules": + return T("menu.rules_desc") + case "language": + return T("menu.language_desc") + case "quit": + return T("menu.quit_desc") + } + return "" +} + // AppModel — корневая модель приложения: меню, экран правил или // запущенная игра. type AppModel struct { - state appState - cursor int - rulesCursor int - options []menuOption - rulesText string - settings stakeSettings - playerCount playerCountSettings - blackjack blackjackSettings - checkers checkersSettings - activeGame tea.Model - quitting bool + 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 + chess chessSettings + goSetup goSettings + minesweeper minesweeperSettings + activeGame tea.Model + quitting bool } func NewAppModel() AppModel { - options := make([]menuOption, 0, len(gameRegistry)+2) - for i, g := range gameRegistry { - options = append(options, menuOption{label: g.Name, desc: g.Description, kind: "game", gameIdx: i}) + options := make([]menuOption, 0, len(gameRegistry)+3) + for i := range gameRegistry { + options = append(options, menuOption{kind: "game", gameIdx: i}) } - options = append(options, menuOption{label: "Правила", desc: "Прочитать правила выбранной игры", kind: "rules"}) - options = append(options, menuOption{label: "Выход", desc: "Закрыть коллекцию игр", kind: "quit"}) + options = append(options, menuOption{kind: "rules"}) + options = append(options, menuOption{kind: "language"}) + options = append(options, menuOption{kind: "quit"}) return AppModel{ - state: stateMenu, - options: options, + state: stateLanguage, + langCursor: int(CurrentLang()), + options: options, + termHeight: 24, + termWidth: 80, } } @@ -328,9 +537,30 @@ func (m AppModel) Init() tea.Cmd { } func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { + m.termWidth = sizeMsg.Width + m.termHeight = sizeMsg.Height + // на случай, если терминал реально изменили размером прямо во + // время сессии — курсор мог оказаться за пределами уже другого + // видимого окна, пересчитываем прокрутку под новую высоту + m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) + m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) + m.clampRulesScroll() + // пропускаем дальше — активная игра тоже может интересоваться + // размером окна в будущем; сейчас просто не теряем ход + if m.state == statePlaying && m.activeGame != nil { + updated, cmd := m.activeGame.Update(msg) + m.activeGame = updated + return m, cmd + } + return m, nil + } + switch m.state { case statePlaying: return m.updatePlaying(msg) + case stateLanguage: + return m.updateLanguage(msg) case stateRulesMenu: return m.updateRulesMenu(msg) case stateRules: @@ -343,11 +573,41 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateBlackjackSettings(msg) case stateCheckersSettings: return m.updateCheckersSettings(msg) + case stateChessSettings: + return m.updateChessSettings(msg) + case stateGoSettings: + return m.updateGoSettings(msg) + case stateMinesweeperSettings: + return m.updateMinesweeperSettings(msg) default: return m.updateMenu(msg) } } +func (m AppModel) updateLanguage(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 "up", "k": + if m.langCursor > 0 { + m.langCursor-- + } + case "down", "j": + if m.langCursor < 1 { + m.langCursor++ + } + case "enter": + SetLanguage(Lang(m.langCursor)) + m.state = stateMenu + } + return m, nil +} + func (m AppModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { @@ -361,10 +621,12 @@ func (m AppModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } + m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) case "down", "j": if m.cursor < len(m.options)-1 { m.cursor++ } + m.menuScroll = clampWindowScroll(m.cursor, m.menuScroll, m.menuVisibleItems(), len(m.options)) case "enter": selected := m.options[m.cursor] switch selected.kind { @@ -387,13 +649,29 @@ func (m AppModel) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { m.checkers = newCheckersSettings(selected.gameIdx) m.state = stateCheckersSettings return m, nil + case entry.UsesChessSettings: + m.chess = newChessSettings(selected.gameIdx) + m.state = stateChessSettings + return m, nil + case entry.UsesGoSettings: + m.goSetup = newGoSettings(selected.gameIdx) + m.state = stateGoSettings + return m, nil + case entry.UsesMinesweeperSettings: + m.minesweeper = newMinesweeperSettings(selected.gameIdx) + m.state = stateMinesweeperSettings + return m, nil } m.state = statePlaying m.activeGame = entry.New() return m, m.activeGame.Init() case "rules": m.rulesCursor = 0 + m.rulesMenuScroll = 0 m.state = stateRulesMenu + case "language": + m.langCursor = int(CurrentLang()) + m.state = stateLanguage case "quit": m.quitting = true return m, tea.Quit @@ -417,24 +695,159 @@ func (m AppModel) updateRulesMenu(msg tea.Msg) (tea.Model, tea.Cmd) { if m.rulesCursor > 0 { m.rulesCursor-- } + m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) case "down", "j": if m.rulesCursor < len(gameRegistry)-1 { m.rulesCursor++ } + m.rulesMenuScroll = clampWindowScroll(m.rulesCursor, m.rulesMenuScroll, m.rulesMenuVisibleItems(), len(gameRegistry)) case "enter": - m.rulesText = gameRegistry[m.rulesCursor].Rules + m.rulesText = gameRegistry[m.rulesCursor].Rules() + m.rulesScroll = 0 m.state = stateRules } return m, nil } -func (m AppModel) updateRules(msg tea.Msg) (tea.Model, tea.Cmd) { - if keyMsg, ok := msg.(tea.KeyMsg); ok { - switch keyMsg.String() { - case "q", "esc", "enter", "ctrl+c": - m.state = stateRulesMenu - } +// clampWindowScroll подбирает минимальную корректировку scroll так, +// чтобы cursor гарантированно попадал в видимое окно +// [scroll, scroll+visibleCount) — используется и для списка игр в +// главном меню, и для подменю правил, чтобы курсор при навигации +// стрелками никогда не убегал за пределы экрана. +func clampWindowScroll(cursor, scroll, visibleCount, total int) int { + if visibleCount <= 0 { + return 0 } + if cursor < scroll { + scroll = cursor + } + if cursor >= scroll+visibleCount { + scroll = cursor - visibleCount + 1 + } + if scroll < 0 { + scroll = 0 + } + maxScroll := total - visibleCount + if maxScroll < 0 { + maxScroll = 0 + } + if scroll > maxScroll { + scroll = maxScroll + } + return scroll +} + +// asciiBannerLines — число строк в ASCII-баннере (для расчёта, сколько +// места остаётся под сам список пунктов меню). +func asciiBannerLines() int { + return strings.Count(asciiBanner, "\n") + 1 +} + +// menuBannerCompactThreshold — если высота терминала меньше этого +// значения, полноразмерный ASCII-баннер (13 строк) заменяется на +// компактную однострочную надпись — иначе на невысоких терминалах +// под сам список игр совсем не остаётся места. +const menuBannerCompactThreshold = 30 + +const menuBannerCompact = "=== GO GAMES COLLECTION ===" + +// menuUsesCompactBanner решает, показывать ли компактный заголовок +// вместо полного ASCII-баннера при текущей высоте терминала. +func (m AppModel) menuUsesCompactBanner() bool { + return m.termHeight < menuBannerCompactThreshold +} + +// menuBannerLineCount — сколько строк реально займёт заголовок меню +// (баннер или его компактная замена) при текущей высоте терминала. +func (m AppModel) menuBannerLineCount() int { + if m.menuUsesCompactBanner() { + return 1 + } + return asciiBannerLines() +} + +// menuVisibleItems — сколько пунктов главного меню помещается на +// экран одновременно. Каждый пункт занимает 2 строки (название + +// описание), плюс заголовок, подзаголовок, пустая строка и подсказка +// снизу — фиксированный "хвост" интерфейса. +func (m AppModel) menuVisibleItems() int { + overhead := m.menuBannerLineCount() + 4 + 2 // подзаголовок + 2 пустые строки + подсказка + место под ▲/▼ + available := m.termHeight - overhead + items := available / 2 + if items < 1 { + items = 1 + } + return items +} + +// rulesMenuVisibleItems — сколько пунктов подменю правил помещается +// на экран одновременно (по одной строке на пункт, без описания). +func (m AppModel) rulesMenuVisibleItems() int { + overhead := 4 + 2 // заголовок + пустая строка + пустая строка + подсказка + место под ▲/▼ + available := m.termHeight - overhead + if available < 1 { + available = 1 + } + return available +} + +// rulesVisibleLines — сколько строк текста правил помещается на +// экран одновременно (с учётом заголовка и подсказки снизу). +func (m AppModel) rulesVisibleLines() int { + h := m.termHeight - 4 + if h < 3 { + h = 3 + } + return h +} + +func (m AppModel) rulesTotalLines() int { + return len(strings.Split(m.rulesText, "\n")) +} + +func (m AppModel) rulesMaxScroll() int { + max := m.rulesTotalLines() - m.rulesVisibleLines() + if max < 0 { + max = 0 + } + return max +} + +func (m *AppModel) clampRulesScroll() { + if m.rulesScroll < 0 { + m.rulesScroll = 0 + } + if max := m.rulesMaxScroll(); m.rulesScroll > max { + m.rulesScroll = max + } +} + +func (m AppModel) updateRules(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 "q", "esc": + m.state = stateRulesMenu + return m, nil + case "up", "k": + m.rulesScroll-- + case "down", "j": + m.rulesScroll++ + case "pgup", "b": + m.rulesScroll -= m.rulesVisibleLines() + case "pgdown", "f", " ": + m.rulesScroll += m.rulesVisibleLines() + case "home", "g": + m.rulesScroll = 0 + case "end", "G": + m.rulesScroll = m.rulesMaxScroll() + } + m.clampRulesScroll() return m, nil } @@ -522,6 +935,91 @@ func (m AppModel) updateCheckersSettings(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m AppModel) updateChessSettings(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 = stateMenu + return m, nil + case "up", "k", "left", "h": + m.chess.cycle(-1) + case "down", "j", "right", "l": + m.chess.cycle(1) + case "enter": + entry := gameRegistry[m.chess.gameIdx] + depth := chessLevels[m.chess.levelIdx].Depth + m.activeGame = entry.NewChessWithDifficulty(depth) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updateGoSettings(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 = stateMenu + return m, nil + case "up", "k": + if m.goSetup.fieldCursor > 0 { + m.goSetup.fieldCursor-- + } + case "down", "j": + if m.goSetup.fieldCursor < 1 { + m.goSetup.fieldCursor++ + } + case "left", "h": + m.goSetup.cycle(-1) + case "right", "l": + m.goSetup.cycle(1) + case "enter": + entry := gameRegistry[m.goSetup.gameIdx] + m.activeGame = entry.NewGoWithSettings(m.goSetup.size(), m.goSetup.difficulty()) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + +func (m AppModel) updateMinesweeperSettings(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 = stateMenu + return m, nil + case "up", "k", "left", "h": + m.minesweeper.cycle(-1) + case "down", "j", "right", "l": + m.minesweeper.cycle(1) + case "enter": + entry := gameRegistry[m.minesweeper.gameIdx] + p := minesweeperPresets[m.minesweeper.presetIdx] + m.activeGame = entry.NewMinesweeperWithPreset(p.Width, p.Height, p.Mines) + m.state = statePlaying + return m, m.activeGame.Init() + } + return m, nil +} + func (m AppModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { @@ -582,6 +1080,16 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { backToMenu = tm.backToMenu case CheckersModel: backToMenu = tm.backToMenu + case ChessModel: + backToMenu = tm.backToMenu + case CorpseStarchModel: + backToMenu = tm.backToMenu + case GoModel: + backToMenu = tm.backToMenu + case MinesweeperModel: + backToMenu = tm.backToMenu + case WayfarerModel: + backToMenu = tm.backToMenu } if backToMenu { m.state = stateMenu @@ -593,11 +1101,13 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { func (m AppModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } switch m.state { case statePlaying: return m.activeGame.View() + case stateLanguage: + return m.viewLanguage() case stateRulesMenu: return m.viewRulesMenu() case stateRules: @@ -610,74 +1120,152 @@ func (m AppModel) View() string { return m.viewBlackjackSettings() case stateCheckersSettings: return m.viewCheckersSettings() + case stateChessSettings: + return m.viewChessSettings() + case stateGoSettings: + return m.viewGoSettings() + case stateMinesweeperSettings: + return m.viewMinesweeperSettings() default: return m.viewMenu() } } -func (m AppModel) viewMenu() string { +func (m AppModel) viewLanguage() string { var b strings.Builder - fmt.Fprintln(&b, menuTitleStyle.Render(asciiBanner)) - fmt.Fprintln(&b, menuSubtitleStyle.Render("Коллекция консольных игр на Go")) + fmt.Fprintln(&b, menuTitleStyle.Render(T("lang.title"))) fmt.Fprintln(&b) - for i, opt := range m.options { + options := []string{T("lang.ru"), T("lang.en")} + for i, opt := range options { cursor := " " - label := menuItemStyle.Render(opt.label) - if i == m.cursor { + label := menuItemStyle.Render(opt) + if i == m.langCursor { cursor = menuCursorStyle.Render("> ") - label = menuCursorStyle.Render(opt.label) + label = menuCursorStyle.Render(opt) } fmt.Fprintf(&b, "%s%s\n", cursor, label) - if opt.desc != "" { - fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(opt.desc)) - } } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] подтвердить [q] выход")) + fmt.Fprintln(&b, menuHintStyle.Render(T("lang.hint"))) + return b.String() +} + +func (m AppModel) viewMenu() string { + var b strings.Builder + if m.menuUsesCompactBanner() { + fmt.Fprintln(&b, menuTitleStyle.Render(menuBannerCompact)) + } else { + fmt.Fprintln(&b, menuTitleStyle.Render(asciiBanner)) + } + fmt.Fprintln(&b, menuSubtitleStyle.Render(T("app.subtitle"))) + fmt.Fprintln(&b) + + visible := m.menuVisibleItems() + start := m.menuScroll + end := start + visible + if end > len(m.options) { + end = len(m.options) + } + + if start > 0 { + fmt.Fprintln(&b, dimStyle.Render(" ▲")) + } + for i := start; i < end; i++ { + opt := m.options[i] + cursor := " " + label := menuItemStyle.Render(opt.label()) + if i == m.cursor { + cursor = menuCursorStyle.Render("> ") + label = menuCursorStyle.Render(opt.label()) + } + fmt.Fprintf(&b, "%s%s\n", cursor, label) + if d := opt.desc(); d != "" { + fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(d)) + } + } + if end < len(m.options) { + fmt.Fprintln(&b, dimStyle.Render(" ▼")) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("menu.hint"))) return b.String() } func (m AppModel) viewRulesMenu() string { var b strings.Builder - fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА: ВЫБЕРИТЕ ИГРУ ===")) + fmt.Fprintln(&b, menuTitleStyle.Render(T("rulesmenu.title"))) fmt.Fprintln(&b) - for i, entry := range gameRegistry { + + visible := m.rulesMenuVisibleItems() + start := m.rulesMenuScroll + end := start + visible + if end > len(gameRegistry) { + end = len(gameRegistry) + } + + if start > 0 { + fmt.Fprintln(&b, dimStyle.Render(" ▲")) + } + for i := start; i < end; i++ { + entry := gameRegistry[i] cursor := " " - label := menuItemStyle.Render(entry.Name) + label := menuItemStyle.Render(entry.Name()) if i == m.rulesCursor { cursor = menuCursorStyle.Render("> ") - label = menuCursorStyle.Render(entry.Name) + label = menuCursorStyle.Render(entry.Name()) } fmt.Fprintf(&b, "%s%s\n", cursor, label) } + if end < len(gameRegistry) { + fmt.Fprintln(&b, dimStyle.Render(" ▼")) + } + fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] выбор [enter] показать правила [esc/q] назад в меню")) + fmt.Fprintln(&b, menuHintStyle.Render(T("rulesmenu.hint"))) return b.String() } func (m AppModel) viewRules() string { var b strings.Builder - fmt.Fprintln(&b, menuTitleStyle.Render("=== ПРАВИЛА ===")) + fmt.Fprintln(&b, menuTitleStyle.Render(T("rules.title"))) fmt.Fprintln(&b) - fmt.Fprintln(&b, m.rulesText) - fmt.Fprintln(&b, menuHintStyle.Render("[enter/esc/q] назад к списку игр")) + + lines := strings.Split(m.rulesText, "\n") + visible := m.rulesVisibleLines() + start := m.rulesScroll + if start > len(lines) { + start = len(lines) + } + end := start + visible + if end > len(lines) { + end = len(lines) + } + fmt.Fprintln(&b, strings.Join(lines[start:end], "\n")) + + maxScroll := m.rulesMaxScroll() + scrollInfo := "" + if maxScroll > 0 { + scrollInfo = " " + Tf("rules.scroll_info", start+1, end, len(lines)) + } + fmt.Fprintln(&b, menuHintStyle.Render(T("rules.hint")+scrollInfo)) return b.String() } func (m AppModel) viewSettings() string { var b strings.Builder entry := gameRegistry[m.settings.gameIdx] - fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА СТАВКИ: %s ===", entry.Name))) + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("stake.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ - {"Стартовый капитал", fmt.Sprintf("%d", m.settings.capital())}, - {"Ставка (анте) за раунд", fmt.Sprintf("%d", m.settings.ante())}, - {"Множитель банка при Тонке", fmt.Sprintf("×%d", m.settings.multiplier())}, + {T("stake.capital"), fmt.Sprintf("%d", m.settings.capital())}, + {T("stake.ante"), fmt.Sprintf("%d", m.settings.ante())}, + {T("stake.tonk_mult"), fmt.Sprintf("×%d", m.settings.multiplier())}, } for i, f := range fields { cursor := " " @@ -692,40 +1280,40 @@ func (m AppModel) viewSettings() string { fmt.Fprintln(&b) pot := m.settings.ante() * 4 fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf( - "Банк на 4 игроков: %d. Победа Тонком: ×%d = %d.", - pot, m.settings.multiplier(), pot*m.settings.multiplier()))) + "%s: %d. %s: ×%d = %d.", + T("stake.pot_info"), pot, T("stake.tonk_win"), m.settings.multiplier(), pot*m.settings.multiplier()))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад")) + fmt.Fprintln(&b, menuHintStyle.Render(T("stake.hint"))) return b.String() } func (m AppModel) viewPlayerCount() string { var b strings.Builder entry := gameRegistry[m.playerCount.gameIdx] - fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== ЧИСЛО ИГРОКОВ: %s ===", entry.Name))) + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("playercount.title"), entry.Name()))) fmt.Fprintln(&b) - fmt.Fprintf(&b, "%s\n", menuCursorStyle.Render(fmt.Sprintf(" ← %d игроков (включая вас) →", m.playerCount.count))) + fmt.Fprintf(&b, "%s\n", menuCursorStyle.Render(fmt.Sprintf(" ← %d %s →", m.playerCount.count, T("playercount.line")))) fmt.Fprintln(&b) - fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами.", m.playerCount.count-1))) + fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[←→/hl] изменить число игроков (2-6) [enter] начать игру [esc/q] назад")) + fmt.Fprintln(&b, menuHintStyle.Render(T("playercount.hint"))) return b.String() } func (m AppModel) viewBlackjackSettings() string { var b strings.Builder entry := gameRegistry[m.blackjack.gameIdx] - fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== НАСТРОЙКА: %s ===", entry.Name))) + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("bjsettings.title"), entry.Name()))) fmt.Fprintln(&b) fields := []struct { label string value string }{ - {"Число игроков (включая вас)", fmt.Sprintf("%d", m.blackjack.playerCount)}, - {"Стартовый капитал", fmt.Sprintf("%d", m.blackjack.capital())}, - {"Ставка за раунд", fmt.Sprintf("%d", m.blackjack.bet())}, + {T("bjsettings.players"), fmt.Sprintf("%d", m.blackjack.playerCount)}, + {T("bjsettings.capital"), fmt.Sprintf("%d", m.blackjack.capital())}, + {T("bjsettings.bet"), fmt.Sprintf("%d", m.blackjack.bet())}, } for i, f := range fields { cursor := " " @@ -740,25 +1328,25 @@ func (m AppModel) viewBlackjackSettings() string { fmt.Fprintln(&b) botsCount := m.blackjack.playerCount - 1 if botsCount > 0 { - fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(fmt.Sprintf("Вы + %d бот(ов) со случайными именами, каждый играет против дилера отдельно.", botsCount))) + fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("bjsettings.multi", botsCount))) } else { - fmt.Fprintln(&b, menuDescStyle.Render("Вы играете в одиночку против дилера.")) + fmt.Fprintln(&b, menuDescStyle.Render(T("bjsettings.solo"))) } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад")) + fmt.Fprintln(&b, menuHintStyle.Render(T("bjsettings.hint"))) return b.String() } func (m AppModel) viewCheckersSettings() string { var b strings.Builder entry := gameRegistry[m.checkers.gameIdx] - fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== УРОВЕНЬ СЛОЖНОСТИ: %s ===", entry.Name))) + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name()))) fmt.Fprintln(&b) for i, lvl := range checkersLevels { cursor := " " - line := fmt.Sprintf("%s (%s)", lvl.Label, lvl.Desc) + line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) if i == m.checkers.levelIdx { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) @@ -767,6 +1355,76 @@ func (m AppModel) viewCheckersSettings() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render("[↑↓/←→] выбор уровня [enter] начать игру [esc/q] назад")) + fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) + return b.String() +} + +func (m AppModel) viewChessSettings() string { + var b strings.Builder + entry := gameRegistry[m.chess.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("checkerslvl.title"), entry.Name()))) + fmt.Fprintln(&b) + + for i, lvl := range chessLevels { + cursor := " " + line := fmt.Sprintf("%s (%s)", T(lvl.LabelKey), T(lvl.DescKey)) + if i == m.chess.levelIdx { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) + return b.String() +} + +func (m AppModel) viewGoSettings() string { + var b strings.Builder + entry := gameRegistry[m.goSetup.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("gosettings.title"), entry.Name()))) + fmt.Fprintln(&b) + + fields := []struct { + label string + value string + }{ + {T("gosettings.boardsize"), fmt.Sprintf("%dx%d", m.goSetup.size(), m.goSetup.size())}, + {T("gosettings.difficulty"), T(goDifficultyLevels[m.goSetup.difficultyIdx].LabelKey)}, + } + for i, f := range fields { + cursor := " " + line := fmt.Sprintf("%-30s ← %s →", f.label, f.value) + if i == m.goSetup.fieldCursor { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + return b.String() +} + +func (m AppModel) viewMinesweeperSettings() string { + var b strings.Builder + entry := gameRegistry[m.minesweeper.gameIdx] + fmt.Fprintln(&b, menuTitleStyle.Render(fmt.Sprintf("=== %s: %s ===", T("mssettings.title"), entry.Name()))) + fmt.Fprintln(&b) + + for i, p := range minesweeperPresets { + cursor := " " + line := fmt.Sprintf("%s — %dx%d, %d 💣", T(p.LabelKey), p.Width, p.Height, p.Mines) + if i == m.minesweeper.presetIdx { + cursor = menuCursorStyle.Render("> ") + line = menuCursorStyle.Render(line) + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("mssettings.hint"))) return b.String() } diff --git a/menu_test.go b/menu_test.go index 1be18e9..e729b41 100644 --- a/menu_test.go +++ b/menu_test.go @@ -7,13 +7,219 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -func TestMenuNavigation(t *testing.T) { +// newTestAppModel создаёт AppModel и сразу подтверждает язык по +// умолчанию (первый пункт — русский), чтобы остальные тесты меню +// могли считать, что стартуют сразу в stateMenu, как и раньше. +func newTestAppModel() AppModel { m := NewAppModel() + next, _ := m.Update(key("enter")) + return next.(AppModel) +} + +func TestLanguageScreenIsInitialState(t *testing.T) { + SetLanguage(LangRU) + m := NewAppModel() + if m.state != stateLanguage { + t.Fatalf("ожидалось начальное состояние stateLanguage, получено %v", m.state) + } +} + +func TestLanguageScreenSelectEnglish(t *testing.T) { + defer SetLanguage(LangRU) // не влияем на остальные тесты пакета + m := NewAppModel() + if m.langCursor != 0 { + t.Fatalf("ожидался курсор на первом пункте (русский), получено %d", m.langCursor) + } + + next, _ := m.Update(key("down")) + m = next.(AppModel) + if m.langCursor != 1 { + t.Fatalf("ожидался курсор на английском после down, получено %d", m.langCursor) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("ожидался переход в stateMenu после выбора языка") + } + if CurrentLang() != LangEN { + t.Fatalf("ожидался установленный язык LangEN") + } + if T("menu.quit") != "Quit" { + t.Errorf("после выбора английского T(\"menu.quit\") должен вернуть 'Quit', получено %q", T("menu.quit")) + } +} + +func TestLanguageScreenDefaultsToRussian(t *testing.T) { + defer SetLanguage(LangRU) + m := NewAppModel() + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("ожидался переход в stateMenu") + } + if CurrentLang() != LangRU { + t.Fatalf("ожидался язык по умолчанию LangRU") + } +} + +func TestMenuHasLanguageOption(t *testing.T) { + m := newTestAppModel() + found := false + for _, opt := range m.options { + if opt.kind == "language" { + found = true + } + } + if !found { + t.Fatalf("ожидался пункт меню для смены языка") + } +} + +func TestMenuScrollFollowsCursorDown(t *testing.T) { + m := newTestAppModel() + m.termHeight = 20 // тесный терминал — гарантированно меньше пунктов, чем видно целиком + visible := m.menuVisibleItems() + if visible >= len(m.options) { + t.Fatalf("тест предполагает, что не все пункты помещаются сразу") + } + + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { + t.Fatalf("курсор %d выпал из видимого окна [%d,%d) после шага %d", + m.cursor, m.menuScroll, m.menuScroll+visible, i) + } + } +} + +func TestMenuScrollFollowsCursorUp(t *testing.T) { + m := newTestAppModel() + m.termHeight = 20 + visible := m.menuVisibleItems() + + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("up")) + m = next.(AppModel) + if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { + t.Fatalf("курсор %d выпал из видимого окна [%d,%d) при прокрутке вверх", + m.cursor, m.menuScroll, m.menuScroll+visible) + } + } + if m.menuScroll != 0 { + t.Errorf("после возврата к самому первому пункту прокрутка должна была вернуться к 0, получено %d", m.menuScroll) + } +} + +func TestMenuScrollIndicatorsAppearWhenScrolled(t *testing.T) { + m := newTestAppModel() + m.termHeight = 20 + + view := m.viewMenu() + if strings.Contains(view, "▲") { + t.Errorf("в самом начале списка индикатор 'выше' не должен показываться") + } + if !strings.Contains(view, "▼") { + t.Errorf("при неполном списке индикатор 'ниже' должен показываться") + } + + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + view = m.viewMenu() + if !strings.Contains(view, "▲") { + t.Errorf("в конце списка индикатор 'выше' должен показываться") + } + if strings.Contains(view, "▼") { + t.Errorf("в конце списка индикатор 'ниже' не должен показываться") + } +} + +func TestMenuCompactBannerOnSmallTerminal(t *testing.T) { + m := newTestAppModel() + m.termHeight = 20 + if !m.menuUsesCompactBanner() { + t.Errorf("на тесном терминале должен использоваться компактный баннер") + } + view := m.viewMenu() + if !strings.Contains(view, "GO GAMES COLLECTION") { + t.Errorf("компактный заголовок должен присутствовать в рендере") + } +} + +func TestMenuFullBannerOnLargeTerminal(t *testing.T) { + m := newTestAppModel() + m.termHeight = 50 + if m.menuUsesCompactBanner() { + t.Errorf("на просторном терминале должен использоваться полный баннер") + } + if m.menuVisibleItems() < len(m.options) { + t.Errorf("на просторном терминале весь список должен помещаться целиком") + } +} + +func TestRulesMenuScrollFollowsCursor(t *testing.T) { + m := newTestAppModel() + m.termHeight = 12 + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + if m.state != stateRulesMenu { + t.Fatalf("ожидалось состояние stateRulesMenu") + } + + visible := m.rulesMenuVisibleItems() + if visible >= len(gameRegistry) { + t.Fatalf("тест предполагает, что не все игры помещаются сразу при высоте 12") + } + + for i := 0; i < len(gameRegistry)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + if m.rulesCursor < m.rulesMenuScroll || m.rulesCursor >= m.rulesMenuScroll+visible { + t.Fatalf("курсор правил %d выпал из видимого окна [%d,%d)", + m.rulesCursor, m.rulesMenuScroll, m.rulesMenuScroll+visible) + } + } +} + +func TestMenuScrollRecalculatedOnResize(t *testing.T) { + m := newTestAppModel() + m.termHeight = 50 // просторный терминал — весь список виден, курсор можно увести глубоко + for i := 0; i < len(m.options)-1; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + if m.cursor != len(m.options)-1 { + t.Fatalf("ожидался курсор на последнем пункте") + } + + // пользователь на лету уменьшает терминал — курсор должен остаться видимым + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 15}) + m = next.(AppModel) + visible := m.menuVisibleItems() + if m.cursor < m.menuScroll || m.cursor >= m.menuScroll+visible { + t.Errorf("после уменьшения терминала курсор %d должен был остаться в видимом окне [%d,%d)", + m.cursor, m.menuScroll, m.menuScroll+visible) + } +} + +func TestMenuNavigation(t *testing.T) { + m := newTestAppModel() if m.state != stateMenu { t.Fatalf("ожидалось начальное состояние stateMenu") } - if len(m.options) != len(gameRegistry)+2 { - t.Fatalf("ожидалось %d пунктов меню, получено %d", len(gameRegistry)+2, len(m.options)) + if len(m.options) != len(gameRegistry)+3 { + t.Fatalf("ожидалось %d пунктов меню, получено %d", len(gameRegistry)+3, len(m.options)) } next, _ := m.Update(key("down")) @@ -30,7 +236,7 @@ func TestMenuNavigation(t *testing.T) { } func TestMenuLeadsToStakeSettings(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // первый пункт меню — первая (и пока единственная) игра, которая // использует ставки -> должен открыться экран настроек, а не игра next, _ := m.Update(key("enter")) @@ -44,7 +250,7 @@ func TestMenuLeadsToStakeSettings(t *testing.T) { } func TestStakeSettingsAdjustAndStart(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("enter")) // входим в настройки m = next.(AppModel) @@ -87,7 +293,7 @@ func TestStakeSettingsAdjustAndStart(t *testing.T) { } func TestStakeSettingsEscReturnsToMenu(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) if m.state != stateSettings { @@ -101,7 +307,7 @@ func TestStakeSettingsEscReturnsToMenu(t *testing.T) { } func TestStakeSettingsCycleClampsAtBounds(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("enter")) m = next.(AppModel) @@ -117,7 +323,7 @@ func TestStakeSettingsCycleClampsAtBounds(t *testing.T) { } func TestMenuShowsRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // переходим к пункту "Правила" (сразу после списка игр) for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) @@ -155,7 +361,7 @@ func TestMenuShowsRules(t *testing.T) { } func TestMenuShowsDurakRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -176,7 +382,7 @@ func TestMenuShowsDurakRules(t *testing.T) { } func TestMenuLeadsToPlayerCountForDurak(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) // второй пункт меню — Дурак m = next.(AppModel) next, _ = m.Update(key("enter")) @@ -190,7 +396,7 @@ func TestMenuLeadsToPlayerCountForDurak(t *testing.T) { } func TestPlayerCountAdjustAndStart(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // экран выбора числа игроков @@ -220,7 +426,7 @@ func TestPlayerCountAdjustAndStart(t *testing.T) { } func TestPlayerCountClampsAtBounds(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) @@ -243,7 +449,7 @@ func TestPlayerCountClampsAtBounds(t *testing.T) { } func TestPlayerCountEscReturnsToMenu(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) @@ -256,7 +462,7 @@ func TestPlayerCountEscReturnsToMenu(t *testing.T) { } func TestDurakQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("enter")) // экран числа игроков @@ -278,7 +484,7 @@ func TestDurakQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } func TestMenuShowsBlackjackRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -302,7 +508,7 @@ func TestMenuShowsBlackjackRules(t *testing.T) { } func TestMenuLeadsToBlackjackSettings(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // Блэкджек — третий пункт меню (после Тонка и Дурака) next, _ := m.Update(key("down")) m = next.(AppModel) @@ -319,7 +525,7 @@ func TestMenuLeadsToBlackjackSettings(t *testing.T) { } func TestBlackjackSettingsAdjustAndStart(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) @@ -366,7 +572,7 @@ func TestBlackjackSettingsAdjustAndStart(t *testing.T) { } func TestBlackjackSettingsEscReturnsToMenu(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) @@ -381,7 +587,7 @@ func TestBlackjackSettingsEscReturnsToMenu(t *testing.T) { } func TestBlackjackQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("down")) m = next.(AppModel) next, _ = m.Update(key("down")) @@ -405,7 +611,7 @@ func TestBlackjackQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } func TestMenuShowsOneOhOneRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -429,7 +635,7 @@ func TestMenuShowsOneOhOneRules(t *testing.T) { } func TestMenuLeadsToPlayerCountForOneOhOne(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // 101 — четвёртый пункт меню for i := 0; i < 3; i++ { next, _ := m.Update(key("down")) @@ -443,7 +649,7 @@ func TestMenuLeadsToPlayerCountForOneOhOne(t *testing.T) { } func TestOneOhOneQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 3; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -467,7 +673,7 @@ func TestOneOhOneQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } func TestMenuShowsThousandRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -491,7 +697,7 @@ func TestMenuShowsThousandRules(t *testing.T) { } func TestThousandStartsDirectlyWithoutSettingsScreen(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // 1000 — пятый пункт меню, запускается сразу (без экрана настроек) for i := 0; i < 4; i++ { next, _ := m.Update(key("down")) @@ -508,7 +714,7 @@ func TestThousandStartsDirectlyWithoutSettingsScreen(t *testing.T) { } func TestThousandQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 4; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -530,7 +736,7 @@ func TestThousandQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } func TestMenuShowsKlondikeRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -554,7 +760,7 @@ func TestMenuShowsKlondikeRules(t *testing.T) { } func TestKlondikeStartsDirectlyWithoutSettingsScreen(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 5; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -570,7 +776,7 @@ func TestKlondikeStartsDirectlyWithoutSettingsScreen(t *testing.T) { } func TestKlondikeQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 5; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -592,7 +798,7 @@ func TestKlondikeQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } func TestMenuShowsCheckersRules(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < len(gameRegistry); i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -616,7 +822,7 @@ func TestMenuShowsCheckersRules(t *testing.T) { } func TestCheckersLeadsToDifficultySettings(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -632,7 +838,7 @@ func TestCheckersLeadsToDifficultySettings(t *testing.T) { } func TestCheckersDifficultySelectionAndStart(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -664,7 +870,7 @@ func TestCheckersDifficultySelectionAndStart(t *testing.T) { } func TestCheckersDifficultyEscReturnsToMenu(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -679,7 +885,7 @@ func TestCheckersDifficultyEscReturnsToMenu(t *testing.T) { } func TestCheckersQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() for i := 0; i < 6; i++ { next, _ := m.Update(key("down")) m = next.(AppModel) @@ -702,8 +908,597 @@ func TestCheckersQKeyReturnsToMenuNotQuitsApp(t *testing.T) { } } +func TestMenuShowsCorpseStarchRules(t *testing.T) { + m := newTestAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Трупные батончики — девятая запись в реестре (индекс 8) + for i := 0; i < 8; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ТРУПНЫЕ БАТОНЧИКИ") { + t.Fatalf("текст правил должен содержать описание игры, получено начало: %.50s", m.rulesText) + } +} + +func TestCorpseStarchStartsDirectlyWithoutSettingsScreen(t *testing.T) { + withTempConfigDir(t) + m := newTestAppModel() + for i := 0; i < 8; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось немедленное statePlaying, получено %v", m.state) + } + if _, ok := m.activeGame.(CorpseStarchModel); !ok { + t.Fatalf("активная игра должна быть моделью Трупных батончиков") + } +} + +func TestCorpseStarchQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + withTempConfigDir(t) + m := newTestAppModel() + for i := 0; i < 8; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsWayfarerRules(t *testing.T) { + m := newTestAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Странник — двенадцатая запись в реестре (индекс 11) + for i := 0; i < 11; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "СТРАННИК") { + t.Fatalf("текст правил должен содержать описание Странника, получено начало: %.50s", m.rulesText) + } +} + +func TestWayfarerStartsDirectlyWithoutSettingsScreen(t *testing.T) { + withTempConfigDir(t) + m := newTestAppModel() + for i := 0; i < 11; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось немедленное statePlaying, получено %v", m.state) + } + if _, ok := m.activeGame.(WayfarerModel); !ok { + t.Fatalf("активная игра должна быть моделью Странника") + } +} + +func TestWayfarerQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + withTempConfigDir(t) + m := newTestAppModel() + for i := 0; i < 11; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsMinesweeperRules(t *testing.T) { + m := newTestAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Сапёр — одиннадцатая запись в реестре (индекс 10) + for i := 0; i < 10; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "САПЁР") { + t.Fatalf("текст правил должен содержать описание Сапёра, получено начало: %.50s", m.rulesText) + } +} + +func TestMinesweeperLeadsToSettings(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 10; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateMinesweeperSettings { + t.Fatalf("ожидалось состояние stateMinesweeperSettings, получено %v", m.state) + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения пресета") + } +} + +func TestMinesweeperSettingsSelectionAndStart(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 10; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран настроек + m = next.(AppModel) + if m.minesweeper.presetIdx != 0 { + t.Fatalf("ожидался пресет по умолчанию 0 (Новичок), получено %d", m.minesweeper.presetIdx) + } + + next, _ = m.Update(key("down")) // Новичок -> Любитель + m = next.(AppModel) + if m.minesweeper.presetIdx != 1 { + t.Fatalf("ожидался пресет 1 (Любитель) после переключения, получено %d", m.minesweeper.presetIdx) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения пресета") + } + mm, ok := m.activeGame.(MinesweeperModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Сапёра") + } + if mm.game.Width != 16 || mm.game.Height != 16 || mm.game.MineCount != 40 { + t.Errorf("ожидался пресет 'Любитель' (16x16, 40 мин) в запущенной игре, получено %dx%d/%d", + mm.game.Width, mm.game.Height, mm.game.MineCount) + } +} + +func TestMinesweeperSettingsEscReturnsToMenu(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 10; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране настроек должен вернуть в меню") + } +} + +func TestMinesweeperQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 10; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран настроек + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsGoRules(t *testing.T) { + m := newTestAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Го — десятая запись в реестре (индекс 9) + for i := 0; i < 9; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ГО (GO)") { + t.Fatalf("текст правил должен содержать описание Го, получено начало: %.50s", m.rulesText) + } +} + +func TestGoLeadsToSettings(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 9; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateGoSettings { + t.Fatalf("ожидалось состояние stateGoSettings для Го, получено %v", m.state) + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения настроек") + } +} + +func TestGoSettingsSelectionAndStart(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 9; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран настроек + m = next.(AppModel) + if m.goSetup.size() != 9 { + t.Fatalf("ожидался размер доски по умолчанию 9, получено %d", m.goSetup.size()) + } + + next, _ = m.Update(key("right")) // 9 -> 13 + m = next.(AppModel) + if m.goSetup.size() != 13 { + t.Fatalf("ожидался размер доски 13 после переключения, получено %d", m.goSetup.size()) + } + + next, _ = m.Update(key("down")) // переходим к полю сложности + m = next.(AppModel) + if m.goSetup.fieldCursor != 1 { + t.Fatalf("ожидался курсор на поле сложности (1), получено %d", m.goSetup.fieldCursor) + } + next, _ = m.Update(key("right")) // Простой -> Средний + m = next.(AppModel) + if m.goSetup.difficulty() != GoDifficultyMedium { + t.Fatalf("ожидалась сложность Средний после переключения") + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения настроек") + } + gm, ok := m.activeGame.(GoModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Го") + } + if gm.game.Size != 13 { + t.Errorf("ожидался размер доски 13 в запущенной игре, получено %d", gm.game.Size) + } + if gm.bot.Difficulty != GoDifficultyMedium { + t.Errorf("ожидалась сложность Средний в запущенной игре") + } +} + +func TestGoSettingsEscReturnsToMenu(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 9; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране настроек должен вернуть в меню") + } +} + +func TestGoQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 9; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран настроек + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestMenuShowsChessRules(t *testing.T) { + m := newTestAppModel() + for i := 0; i < len(gameRegistry); i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // подменю правил + m = next.(AppModel) + + // Шахматы — восьмая запись в реестре (индекс 7) + for i := 0; i < 7; i++ { + next, _ = m.Update(key("down")) + m = next.(AppModel) + } + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + if !strings.Contains(m.rulesText, "ШАХМАТЫ") { + t.Fatalf("текст правил должен содержать описание Шахмат, получено начало: %.50s", m.rulesText) + } +} + +func TestChessLeadsToDifficultySettings(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 7; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateChessSettings { + t.Fatalf("ожидалось состояние stateChessSettings для Шахмат, получено %v", m.state) + } + if m.activeGame != nil { + t.Fatalf("игра не должна была запуститься до подтверждения уровня сложности") + } +} + +func TestChessDifficultySelectionAndStart(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 7; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран выбора сложности + m = next.(AppModel) + if m.chess.levelIdx != 0 { + t.Fatalf("ожидался уровень по умолчанию 0 (Простой), получено %d", m.chess.levelIdx) + } + + next, _ = m.Update(key("down")) // переключаемся на "Сильный" + m = next.(AppModel) + if m.chess.levelIdx != 1 { + t.Fatalf("ожидался уровень 1 (Сильный) после переключения, получено %d", m.chess.levelIdx) + } + + next, _ = m.Update(key("enter")) + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying после подтверждения уровня") + } + cm, ok := m.activeGame.(ChessModel) + if !ok { + t.Fatalf("активная игра должна быть моделью Шахмат") + } + if cm.bot.Depth != ChessDifficultyHard { + t.Errorf("ожидалась глубина бота %d (Сильный), получено %d", ChessDifficultyHard, cm.bot.Depth) + } +} + +func TestChessDifficultyEscReturnsToMenu(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 7; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) + m = next.(AppModel) + next, _ = m.Update(key("esc")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("esc на экране сложности должен вернуть в меню") + } +} + +func TestChessQKeyReturnsToMenuNotQuitsApp(t *testing.T) { + m := newTestAppModel() + for i := 0; i < 7; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + next, _ := m.Update(key("enter")) // экран сложности + m = next.(AppModel) + next, _ = m.Update(key("enter")) // запускаем игру + m = next.(AppModel) + if m.state != statePlaying { + t.Fatalf("ожидалось statePlaying") + } + + next, _ = m.Update(key("q")) + m = next.(AppModel) + if m.state != stateMenu { + t.Fatalf("нажатие 'q' в игре должно вернуть в меню") + } + if m.quitting { + t.Fatalf("приложение не должно закрываться") + } +} + +func TestRulesScrollDownAndUp(t *testing.T) { + m := newTestAppModel() + m.termHeight = 10 // маленький экран, чтобы правила точно не поместились + m.rulesText = strings.Repeat("строка\n", 50) + m.state = stateRules + m.rulesScroll = 0 + + next, _ := m.Update(key("down")) + m = next.(AppModel) + if m.rulesScroll != 1 { + t.Fatalf("ожидалась прокрутка на 1 строку вниз, получено %d", m.rulesScroll) + } + + next, _ = m.Update(key("up")) + m = next.(AppModel) + if m.rulesScroll != 0 { + t.Fatalf("ожидался возврат к строке 0, получено %d", m.rulesScroll) + } + + next, _ = m.Update(key("up")) + m = next.(AppModel) + if m.rulesScroll != 0 { + t.Errorf("прокрутка не должна была уйти в отрицательные значения, получено %d", m.rulesScroll) + } +} + +func TestRulesScrollClampsAtBottom(t *testing.T) { + m := newTestAppModel() + m.termHeight = 10 + m.rulesText = strings.Repeat("строка\n", 20) + m.state = stateRules + + for i := 0; i < 100; i++ { + next, _ := m.Update(key("down")) + m = next.(AppModel) + } + maxScroll := m.rulesMaxScroll() + if m.rulesScroll != maxScroll { + t.Errorf("прокрутка должна была остановиться на максимуме %d, получено %d", maxScroll, m.rulesScroll) + } +} + +func TestRulesPageDownMovesByVisibleLines(t *testing.T) { + m := newTestAppModel() + m.termHeight = 14 + m.rulesText = strings.Repeat("строка\n", 100) + m.state = stateRules + + next, _ := m.Update(key("pgdown")) + m = next.(AppModel) + if m.rulesScroll != m.rulesVisibleLines() { + t.Errorf("ожидалась прокрутка на %d строк за страницу, получено %d", m.rulesVisibleLines(), m.rulesScroll) + } +} + +func TestRulesHomeAndEndKeys(t *testing.T) { + m := newTestAppModel() + m.termHeight = 10 + m.rulesText = strings.Repeat("строка\n", 50) + m.state = stateRules + + next, _ := m.Update(key("G")) + m = next.(AppModel) + if m.rulesScroll != m.rulesMaxScroll() { + t.Fatalf("'G' должна была прокрутить в самый конец") + } + + next, _ = m.Update(key("g")) + m = next.(AppModel) + if m.rulesScroll != 0 { + t.Errorf("'g' должна была прокрутить в самое начало") + } +} + +func TestRulesScrollResetsWhenReopened(t *testing.T) { + m := newTestAppModel() + m.termHeight = 10 + for i := 0; i < len(gameRegistry); i++ { + n, _ := m.Update(key("down")) + m = n.(AppModel) + } + n, _ := m.Update(key("enter")) // подменю правил + m = n.(AppModel) + n, _ = m.Update(key("enter")) // открываем правила первой игры + m = n.(AppModel) + if m.state != stateRules { + t.Fatalf("ожидалось состояние stateRules") + } + + n, _ = m.Update(key("down")) + m = n.(AppModel) + if m.rulesScroll == 0 { + t.Fatalf("ожидалась прокрутка вниз перед проверкой сброса") + } + + n, _ = m.Update(key("esc")) // назад в подменю правил + m = n.(AppModel) + n, _ = m.Update(key("enter")) // снова открываем те же правила + m = n.(AppModel) + if m.rulesScroll != 0 { + t.Errorf("прокрутка должна была сброситься при повторном открытии правил, получено %d", m.rulesScroll) + } +} + +func TestWindowSizeMsgUpdatesDimensions(t *testing.T) { + m := newTestAppModel() + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = next.(AppModel) + if m.termWidth != 100 || m.termHeight != 40 { + t.Errorf("ожидались обновлённые размеры терминала 100x40, получено %dx%d", m.termWidth, m.termHeight) + } +} + func TestMenuQuit(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() // последний пункт меню — "Выход" for i := 0; i < len(m.options)-1; i++ { next, _ := m.Update(key("down")) @@ -720,7 +1515,7 @@ func TestMenuQuit(t *testing.T) { } func TestGameQKeyReturnsToMenuNotQuitsApp(t *testing.T) { - m := NewAppModel() + m := newTestAppModel() next, _ := m.Update(key("enter")) // открываем настройки ставки m = next.(AppModel) next, _ = m.Update(key("enter")) // подтверждаем настройки и запускаем Тонк diff --git a/minesweeper_game.go b/minesweeper_game.go new file mode 100644 index 0000000..de30c2b --- /dev/null +++ b/minesweeper_game.go @@ -0,0 +1,300 @@ +package main + +import ( + "errors" + "math/rand" + "time" +) + +// ПРИМЕЧАНИЕ О ПРАВИЛАХ: классический Сапёр. Мины расставляются +// только ПОСЛЕ первого клика — так, чтобы кликнутая клетка и все её +// соседи гарантированно не содержали мину (это даёт более приятное, +// обычно достаточно большое первое открытие, а не голую цифру) — +// распространённое улучшение поверх самой базовой гарантии "хотя бы +// кликнутая клетка не мина". "Аккорд" (быстрое открытие соседей у +// уже открытой цифры, если рядом расставлено ровно нужное число +// флагов) реализован, как и в привычных версиях игры. + +var ( + ErrMinesweeperGameOver = errors.New("партия уже завершена") + ErrMinesweeperOutOfBounds = errors.New("клетка вне пределов поля") + ErrMinesweeperFlagged = errors.New("сначала снимите флаг с этой клетки") + ErrMinesweeperAlreadyOpen = errors.New("клетка уже открыта") + ErrMinesweeperNotRevealed = errors.New("эта клетка ещё не открыта") +) + +// MinesweeperPos — координаты клетки (0-based, row/col). +type MinesweeperPos struct { + Row, Col int +} + +// minesweeperCell — состояние одной клетки поля. +type minesweeperCell struct { + Revealed bool + Flagged bool + IsMine bool + AdjacentMines int +} + +// MinesweeperResult — итог завершившейся партии. +type MinesweeperResult struct { + Won bool +} + +// MinesweeperGameState — полное состояние партии в Сапёра. +type MinesweeperGameState struct { + Width, Height int + MineCount int + Board [][]minesweeperCell + + minesPlaced bool // мины расставляются только после первого клика + RevealedCount int + FlagCount int + + Result *MinesweeperResult + + rng *rand.Rand +} + +// NewMinesweeperGame создаёт новое поле width x height с mineCount +// мин (не больше width*height-1 — хотя бы одна клетка гарантированно +// останется небезопасной для мины, чтобы было куда сходить первым +// ходом). Сами мины появятся только после первого хода — см. Reveal. +func NewMinesweeperGame(width, height, mineCount int) *MinesweeperGameState { + maxMines := width*height - 1 + if mineCount > maxMines { + mineCount = maxMines + } + g := &MinesweeperGameState{ + Width: width, Height: height, MineCount: mineCount, + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } + g.Board = make([][]minesweeperCell, height) + for r := range g.Board { + g.Board[r] = make([]minesweeperCell, width) + } + return g +} + +func (g *MinesweeperGameState) inBounds(p MinesweeperPos) bool { + return p.Row >= 0 && p.Row < g.Height && p.Col >= 0 && p.Col < g.Width +} + +func msNeighbors(p MinesweeperPos) []MinesweeperPos { + out := make([]MinesweeperPos, 0, 8) + for dr := -1; dr <= 1; dr++ { + for dc := -1; dc <= 1; dc++ { + if dr == 0 && dc == 0 { + continue + } + out = append(out, MinesweeperPos{Row: p.Row + dr, Col: p.Col + dc}) + } + } + return out +} + +// placeMines расставляет мины случайно (используя переданный +// генератор rnd — так тесты могут вызывать её напрямую +// детерминированно), избегая точки first и всех её соседей +// (безопасная зона первого клика), затем считает число мин-соседей +// для каждой клетки. +func (g *MinesweeperGameState) placeMines(first MinesweeperPos, rnd *rand.Rand) { + safe := map[MinesweeperPos]bool{first: true} + for _, n := range msNeighbors(first) { + if g.inBounds(n) { + safe[n] = true + } + } + + var candidates []MinesweeperPos + for r := 0; r < g.Height; r++ { + for c := 0; c < g.Width; c++ { + p := MinesweeperPos{Row: r, Col: c} + if !safe[p] { + candidates = append(candidates, p) + } + } + } + + count := g.MineCount + if count > len(candidates) { + count = len(candidates) // защитный случай — слишком мало свободных клеток + } + rnd.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) + for i := 0; i < count; i++ { + p := candidates[i] + g.Board[p.Row][p.Col].IsMine = true + } + + for r := 0; r < g.Height; r++ { + for c := 0; c < g.Width; c++ { + p := MinesweeperPos{Row: r, Col: c} + n := 0 + for _, adj := range msNeighbors(p) { + if g.inBounds(adj) && g.Board[adj.Row][adj.Col].IsMine { + n++ + } + } + g.Board[r][c].AdjacentMines = n + } + } + + g.minesPlaced = true +} + +// Reveal открывает клетку pos. Если это первый ход партии, сначала +// расставляет мины (см. placeMines). +func (g *MinesweeperGameState) Reveal(pos MinesweeperPos) error { + if g.Result != nil { + return ErrMinesweeperGameOver + } + if !g.inBounds(pos) { + return ErrMinesweeperOutOfBounds + } + + if !g.minesPlaced { + g.placeMines(pos, g.rng) + } + + cell := &g.Board[pos.Row][pos.Col] + if cell.Flagged { + return ErrMinesweeperFlagged + } + if cell.Revealed { + return ErrMinesweeperAlreadyOpen + } + + if cell.IsMine { + g.revealAllMines() + g.Result = &MinesweeperResult{Won: false} + return nil + } + + g.floodReveal(pos) + g.checkWin() + return nil +} + +// floodReveal открывает pos и, если у неё 0 мин-соседей, рекурсивно +// (через очередь) открывает всех соседей — классическое каскадное +// открытие пустых областей. +func (g *MinesweeperGameState) floodReveal(start MinesweeperPos) { + if g.Board[start.Row][start.Col].Revealed || g.Board[start.Row][start.Col].Flagged { + return + } + queue := []MinesweeperPos{start} + g.Board[start.Row][start.Col].Revealed = true + g.RevealedCount++ + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if g.Board[cur.Row][cur.Col].AdjacentMines != 0 { + continue + } + for _, n := range msNeighbors(cur) { + if !g.inBounds(n) { + continue + } + nc := &g.Board[n.Row][n.Col] + if nc.Revealed || nc.Flagged || nc.IsMine { + continue + } + nc.Revealed = true + g.RevealedCount++ + queue = append(queue, n) + } + } +} + +// Chord — быстрое открытие соседей уже открытой цифры, если число +// флагов вокруг неё совпадает с самой цифрой (вызывается на уже +// открытой клетке). +func (g *MinesweeperGameState) Chord(pos MinesweeperPos) error { + if g.Result != nil { + return ErrMinesweeperGameOver + } + if !g.inBounds(pos) { + return ErrMinesweeperOutOfBounds + } + cell := g.Board[pos.Row][pos.Col] + if !cell.Revealed { + return ErrMinesweeperNotRevealed + } + + flagCount := 0 + for _, n := range msNeighbors(pos) { + if g.inBounds(n) && g.Board[n.Row][n.Col].Flagged { + flagCount++ + } + } + if flagCount != cell.AdjacentMines { + return nil // недостаточно/слишком много флагов — аккорд не срабатывает, это не ошибка + } + + for _, n := range msNeighbors(pos) { + if !g.inBounds(n) { + continue + } + nc := g.Board[n.Row][n.Col] + if nc.Revealed || nc.Flagged { + continue + } + if nc.IsMine { + g.revealAllMines() + g.Result = &MinesweeperResult{Won: false} + return nil + } + g.floodReveal(n) + } + if g.Result == nil { + g.checkWin() + } + return nil +} + +func (g *MinesweeperGameState) revealAllMines() { + for r := 0; r < g.Height; r++ { + for c := 0; c < g.Width; c++ { + if g.Board[r][c].IsMine { + g.Board[r][c].Revealed = true + } + } + } +} + +func (g *MinesweeperGameState) checkWin() { + total := g.Width * g.Height + if g.RevealedCount == total-g.MineCount { + g.Result = &MinesweeperResult{Won: true} + } +} + +// ToggleFlag ставит/снимает флаг на скрытой клетке. +func (g *MinesweeperGameState) ToggleFlag(pos MinesweeperPos) error { + if g.Result != nil { + return ErrMinesweeperGameOver + } + if !g.inBounds(pos) { + return ErrMinesweeperOutOfBounds + } + cell := &g.Board[pos.Row][pos.Col] + if cell.Revealed { + return ErrMinesweeperAlreadyOpen + } + if cell.Flagged { + cell.Flagged = false + g.FlagCount-- + } else { + cell.Flagged = true + g.FlagCount++ + } + return nil +} + +// MinesRemaining — сколько мин, по мнению счётчика, ещё не +// помечено флагом (может уйти в минус при перефлаживании — это +// нормально и так же ведёт себя в привычных версиях игры). +func (g *MinesweeperGameState) MinesRemaining() int { + return g.MineCount - g.FlagCount +} diff --git a/minesweeper_game_test.go b/minesweeper_game_test.go new file mode 100644 index 0000000..1f18d96 --- /dev/null +++ b/minesweeper_game_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "math/rand" + "testing" +) + +func msp(r, c int) MinesweeperPos { return MinesweeperPos{Row: r, Col: c} } + +func TestNewMinesweeperGameEmpty(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + if g.Width != 9 || g.Height != 9 || g.MineCount != 10 { + t.Fatalf("неверные параметры поля: %+v", g) + } + for r := 0; r < 9; r++ { + for c := 0; c < 9; c++ { + if g.Board[r][c].IsMine || g.Board[r][c].Revealed { + t.Fatalf("новое поле не должно содержать мин или открытых клеток до первого хода") + } + } + } +} + +func TestMineCountClampedToLeaveOneSafeCell(t *testing.T) { + g := NewMinesweeperGame(3, 3, 100) + if g.MineCount != 8 { + t.Errorf("ожидалось ограничение до 8 мин (9 клеток - 1), получено %d", g.MineCount) + } +} + +func TestFirstRevealNeverHitsMine(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + for trial := 0; trial < 50; trial++ { + g := NewMinesweeperGame(9, 9, 10) + pos := msp(4, 4) + g.placeMines(pos, rnd) + if g.Board[pos.Row][pos.Col].IsMine { + t.Fatalf("попытка %d: первая открытая клетка не должна быть миной", trial) + } + for _, n := range msNeighbors(pos) { + if g.inBounds(n) && g.Board[n.Row][n.Col].IsMine { + t.Fatalf("попытка %d: сосед первой клетки тоже не должен быть миной", trial) + } + } + } +} + +func TestRevealPlacesMinesLazilyOnFirstCall(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + if g.minesPlaced { + t.Fatalf("мины не должны быть расставлены до первого Reveal") + } + if err := g.Reveal(msp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.minesPlaced { + t.Errorf("мины должны были расставиться после первого Reveal") + } +} + +func TestRevealMineEndsGameAndRevealsAllMines(t *testing.T) { + g := NewMinesweeperGame(5, 5, 3) + g.placeMines(msp(0, 0), rand.New(rand.NewSource(1))) + g.minesPlaced = true + + var minePos MinesweeperPos + found := false + for r := 0; r < 5 && !found; r++ { + for c := 0; c < 5 && !found; c++ { + if g.Board[r][c].IsMine { + minePos = msp(r, c) + found = true + } + } + } + if !found { + t.Fatalf("в тестовом поле должна была быть хотя бы одна мина") + } + + if err := g.Reveal(minePos); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil || g.Result.Won { + t.Fatalf("ожидалось поражение после открытия мины") + } + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine && !g.Board[r][c].Revealed { + t.Errorf("все мины должны были раскрыться при поражении") + } + } + } +} + +func TestFloodRevealOpensZeroRegion(t *testing.T) { + g := NewMinesweeperGame(5, 5, 1) + g.Board[4][4].IsMine = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine { + continue + } + count := 0 + for _, n := range msNeighbors(msp(r, c)) { + if g.inBounds(n) && g.Board[n.Row][n.Col].IsMine { + count++ + } + } + g.Board[r][c].AdjacentMines = count + } + } + g.minesPlaced = true + + if err := g.Reveal(msp(0, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Board[0][0].AdjacentMines != 0 { + t.Fatalf("тестовая настройка неверна: (0,0) должна иметь 0 соседних мин") + } + if !g.Board[0][2].Revealed { + t.Errorf("каскад должен был открыть клетки вдалеке от мины") + } + if g.Board[4][4].Revealed { + t.Errorf("сама мина не должна была открыться просто от каскада рядом") + } +} + +func TestCannotRevealAlreadyOpenCell(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + g.Reveal(msp(4, 4)) + if err := g.Reveal(msp(4, 4)); err != ErrMinesweeperAlreadyOpen { + t.Errorf("ожидалась ошибка повторного открытия, получено: %v", err) + } +} + +func TestCannotRevealFlaggedCell(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + g.ToggleFlag(msp(4, 4)) + if err := g.Reveal(msp(4, 4)); err != ErrMinesweeperFlagged { + t.Errorf("ожидалась ошибка флага, получено: %v", err) + } +} + +func TestToggleFlagOnAndOff(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + if err := g.ToggleFlag(msp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if !g.Board[4][4].Flagged || g.FlagCount != 1 { + t.Errorf("клетка должна была быть помечена флагом") + } + if err := g.ToggleFlag(msp(4, 4)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Board[4][4].Flagged || g.FlagCount != 0 { + t.Errorf("флаг должен был сняться") + } +} + +func TestCannotFlagRevealedCell(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + g.Reveal(msp(4, 4)) + if err := g.ToggleFlag(msp(4, 4)); err != ErrMinesweeperAlreadyOpen { + t.Errorf("ожидалась ошибка попытки пометить открытую клетку, получено: %v", err) + } +} + +func TestOutOfBoundsErrors(t *testing.T) { + g := NewMinesweeperGame(9, 9, 10) + if err := g.Reveal(msp(-1, 0)); err != ErrMinesweeperOutOfBounds { + t.Errorf("ожидалась ошибка выхода за границы, получено: %v", err) + } + if err := g.ToggleFlag(msp(0, 9)); err != ErrMinesweeperOutOfBounds { + t.Errorf("ожидалась ошибка выхода за границы, получено: %v", err) + } +} + +func TestWinConditionAllSafeCellsRevealed(t *testing.T) { + g := NewMinesweeperGame(2, 2, 1) + g.Board[1][1].IsMine = true + g.Board[0][0].AdjacentMines = 1 + g.Board[0][1].AdjacentMines = 1 + g.Board[1][0].AdjacentMines = 1 + g.minesPlaced = true + + g.Reveal(msp(0, 0)) + if g.Result != nil { + t.Fatalf("после одной открытой клетки из трёх безопасных победа наступить не должна") + } + g.Reveal(msp(0, 1)) + g.Reveal(msp(1, 0)) + if g.Result == nil || !g.Result.Won { + t.Fatalf("после открытия всех безопасных клеток должна была наступить победа") + } +} + +func TestActionsAfterGameOverReturnError(t *testing.T) { + g := NewMinesweeperGame(5, 5, 3) + g.placeMines(msp(0, 0), rand.New(rand.NewSource(1))) + g.minesPlaced = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine { + g.Reveal(msp(r, c)) + break + } + } + if g.Result != nil { + break + } + } + if g.Result == nil { + t.Fatalf("партия должна была завершиться поражением") + } + if err := g.Reveal(msp(0, 0)); err != ErrMinesweeperGameOver { + t.Errorf("ожидалась ErrMinesweeperGameOver, получено: %v", err) + } + if err := g.ToggleFlag(msp(0, 0)); err != ErrMinesweeperGameOver { + t.Errorf("ожидалась ErrMinesweeperGameOver при флаге, получено: %v", err) + } +} + +func TestChordOpensNeighborsWhenFlagsMatch(t *testing.T) { + g := NewMinesweeperGame(5, 5, 2) + g.minesPlaced = true + g.Board[0][1].IsMine = true + g.Board[1][0].IsMine = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine { + continue + } + n := 0 + for _, nb := range msNeighbors(msp(r, c)) { + if g.inBounds(nb) && g.Board[nb.Row][nb.Col].IsMine { + n++ + } + } + g.Board[r][c].AdjacentMines = n + } + } + g.Board[0][0].Revealed = true + g.RevealedCount++ + + g.ToggleFlag(msp(0, 1)) + g.ToggleFlag(msp(1, 0)) + + if err := g.Chord(msp(0, 0)); err != nil { + t.Fatalf("неожиданная ошибка хорды: %v", err) + } + if !g.Board[1][1].Revealed { + t.Errorf("хорда должна была открыть оставшегося непомеченного соседа (1,1)") + } +} + +func TestChordDoesNothingWhenFlagsDontMatch(t *testing.T) { + g := NewMinesweeperGame(5, 5, 2) + g.minesPlaced = true + g.Board[0][1].IsMine = true + g.Board[1][0].IsMine = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine { + continue + } + n := 0 + for _, nb := range msNeighbors(msp(r, c)) { + if g.inBounds(nb) && g.Board[nb.Row][nb.Col].IsMine { + n++ + } + } + g.Board[r][c].AdjacentMines = n + } + } + g.Board[0][0].Revealed = true + g.RevealedCount++ + // флаг только один, а мин рядом две — хорда не должна сработать + g.ToggleFlag(msp(0, 1)) + + if err := g.Chord(msp(0, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Board[1][1].Revealed { + t.Errorf("хорда не должна была ничего открыть при несовпадении числа флагов") + } +} + +func TestChordRequiresRevealedCell(t *testing.T) { + g := NewMinesweeperGame(5, 5, 2) + g.minesPlaced = true + if err := g.Chord(msp(0, 0)); err != ErrMinesweeperNotRevealed { + t.Errorf("ожидалась ErrMinesweeperNotRevealed, получено: %v", err) + } +} + +func TestChordCanTriggerLoss(t *testing.T) { + g := NewMinesweeperGame(5, 5, 1) + g.minesPlaced = true + g.Board[0][1].IsMine = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if g.Board[r][c].IsMine { + continue + } + n := 0 + for _, nb := range msNeighbors(msp(r, c)) { + if g.inBounds(nb) && g.Board[nb.Row][nb.Col].IsMine { + n++ + } + } + g.Board[r][c].AdjacentMines = n + } + } + g.Board[0][0].Revealed = true + g.RevealedCount++ + // неверно ставим флаг НЕ на мину, а число соседних мин у (0,0) + // равно 1 — хорда решит, что "мина учтена", и откроет саму мину + g.ToggleFlag(msp(1, 1)) + + if err := g.Chord(msp(0, 0)); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if g.Result == nil || g.Result.Won { + t.Fatalf("неверно поставленный флаг при хорде должен был привести к проигрышу") + } +} diff --git a/minesweeper_stress_test.go b/minesweeper_stress_test.go new file mode 100644 index 0000000..f51585a --- /dev/null +++ b/minesweeper_stress_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "math/rand" + "testing" +) + +// TestMinesweeperRandomGamesNoCorruption прогоняет много случайных +// партий на каждом классическом пресете размера, проверяя +// отсутствие паник и что каждая партия корректно завершается +// (победой или поражением) за разумное число ходов. +func TestMinesweeperRandomGamesNoCorruption(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + + presets := []struct{ w, h, mines int }{ + {9, 9, 10}, + {16, 16, 40}, + {30, 16, 99}, + } + + for _, p := range presets { + for game := 0; game < 20; game++ { + g := NewMinesweeperGame(p.w, p.h, p.mines) + const maxMoves = 2000 + moves := 0 + for ; moves < maxMoves && g.Result == nil; moves++ { + var candidates []MinesweeperPos + for r := 0; r < g.Height; r++ { + for c := 0; c < g.Width; c++ { + cell := g.Board[r][c] + if !cell.Revealed && !cell.Flagged { + candidates = append(candidates, msp(r, c)) + } + } + } + if len(candidates) == 0 { + break + } + pos := candidates[rnd.Intn(len(candidates))] + if err := g.Reveal(pos); err != nil { + t.Fatalf("пресет %dx%d/%d, игра %d, ход %d: неожиданная ошибка: %v", + p.w, p.h, p.mines, game, moves, err) + } + } + if g.Result == nil { + t.Fatalf("пресет %dx%d/%d, игра %d: партия не завершилась за %d ходов", + p.w, p.h, p.mines, game, maxMoves) + } + } + } +} diff --git a/minesweeper_tui.go b/minesweeper_tui.go new file mode 100644 index 0000000..8a81035 --- /dev/null +++ b/minesweeper_tui.go @@ -0,0 +1,229 @@ +package main + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + msHiddenStyle = lipgloss.NewStyle().Background(lipgloss.Color("240")).Foreground(lipgloss.Color("240")) + msFlagStyle = lipgloss.NewStyle().Background(lipgloss.Color("240")).Foreground(lipgloss.Color("196")).Bold(true) + msMineStyle = lipgloss.NewStyle().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("255")).Bold(true) + msCursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("220")).Foreground(lipgloss.Color("16")).Bold(true) + msNumberColors = map[int]lipgloss.Color{ + 1: "39", 2: "34", 3: "196", 4: "129", 5: "94", 6: "37", 7: "255", 8: "245", + } +) + +// MinesweeperModel — TUI поверх MinesweeperGameState. +type MinesweeperModel struct { + game *MinesweeperGameState + cursor MinesweeperPos + width, height, mines int + + message string + isError bool + + quitting bool + backToMenu bool +} + +// NewMinesweeperModel создаёт новую партию width x height с mines +// минами. +func NewMinesweeperModel(width, height, mines int) MinesweeperModel { + return MinesweeperModel{ + game: NewMinesweeperGame(width, height, mines), + cursor: MinesweeperPos{Row: height / 2, Col: width / 2}, + width: width, + height: height, + mines: mines, + } +} + +func (m MinesweeperModel) Init() tea.Cmd { return nil } + +func (m *MinesweeperModel) setInfo(key string) { + m.message = T(key) + m.isError = false +} + +func (m *MinesweeperModel) setError(err error) { + m.message = T(err.Error()) + m.isError = true +} + +func (m MinesweeperModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + return m.handleKey(keyMsg) +} + +func (m MinesweeperModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + if key == "q" { + m.backToMenu = true + return m, nil + } + + if m.game.Result != nil { + if key == "n" { + m.game = NewMinesweeperGame(m.width, m.height, m.mines) + m.cursor = MinesweeperPos{Row: m.height / 2, Col: m.width / 2} + m.message = "" + m.isError = false + } + return m, nil + } + + switch key { + case "up", "k": + if m.cursor.Row > 0 { + m.cursor.Row-- + } + case "down", "j": + if m.cursor.Row < m.height-1 { + m.cursor.Row++ + } + case "left", "h": + if m.cursor.Col > 0 { + m.cursor.Col-- + } + case "right", "l": + if m.cursor.Col < m.width-1 { + m.cursor.Col++ + } + case "enter", " ": + cell := m.game.Board[m.cursor.Row][m.cursor.Col] + if cell.Revealed { + if err := m.game.Chord(m.cursor); err != nil { + m.setError(err) + return m, nil + } + if m.game.Result != nil && !m.game.Result.Won { + m.setInfo("minesweeper.msg.boom") + } else { + m.setInfo("minesweeper.msg.chorded") + } + return m, nil + } + if err := m.game.Reveal(m.cursor); err != nil { + m.setError(err) + return m, nil + } + if m.game.Result != nil && !m.game.Result.Won { + m.setInfo("minesweeper.msg.boom") + } else { + m.setInfo("minesweeper.msg.revealed") + } + case "f": + if err := m.game.ToggleFlag(m.cursor); err != nil { + m.setError(err) + return m, nil + } + if m.game.Board[m.cursor.Row][m.cursor.Col].Flagged { + m.setInfo("minesweeper.msg.flagged") + } else { + m.setInfo("minesweeper.msg.unflagged") + } + } + return m, nil +} + +// --- Отрисовка ----------------------------------------------------- + +func (m MinesweeperModel) renderCell(pos MinesweeperPos) string { + cell := m.game.Board[pos.Row][pos.Col] + isCursor := pos == m.cursor && m.game.Result == nil + + if !cell.Revealed { + style := msHiddenStyle + glyph := "▒" + if cell.Flagged { + style = msFlagStyle + glyph = "⚑" + } + if isCursor { + style = msCursorStyle + } + return style.Render(" " + glyph + " ") + } + + if cell.IsMine { + return msMineStyle.Render(" ✱ ") + } + if cell.AdjacentMines == 0 { + style := lipgloss.NewStyle() + if isCursor { + style = msCursorStyle + } + return style.Render(" ") + } + color := msNumberColors[cell.AdjacentMines] + style := lipgloss.NewStyle().Foreground(color).Bold(true) + if isCursor { + style = msCursorStyle + } + return style.Render(fmt.Sprintf(" %d ", cell.AdjacentMines)) +} + +func (m MinesweeperModel) renderBoard() string { + var b strings.Builder + for r := 0; r < m.height; r++ { + for c := 0; c < m.width; c++ { + fmt.Fprint(&b, m.renderCell(MinesweeperPos{Row: r, Col: c})) + } + fmt.Fprintln(&b) + } + return b.String() +} + +func (m MinesweeperModel) View() string { + if m.quitting { + return T("common.goodbye") + } + + g := m.game + var b strings.Builder + + fmt.Fprintln(&b, titleStyle.Render(T("minesweeper.title"))) + fmt.Fprintln(&b) + remaining := g.MineCount - g.FlagCount + fmt.Fprintln(&b, Tf("minesweeper.header.mines", remaining)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderBoard()) + fmt.Fprintln(&b) + + if g.Result != nil { + if g.Result.Won { + fmt.Fprintln(&b, winStyle.Render(T("minesweeper.result.win"))) + } else { + fmt.Fprintln(&b, errorStyle.Render(T("minesweeper.result.lose"))) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render(T("minesweeper.hint.gameover"))) + return b.String() + } + + fmt.Fprintln(&b, T("minesweeper.hint.play")) + + fmt.Fprintln(&b) + if m.message != "" { + if m.isError { + fmt.Fprintln(&b, errorStyle.Render("! "+m.message)) + } else { + fmt.Fprintln(&b, infoStyle.Render(m.message)) + } + } + + return b.String() +} diff --git a/minesweeper_tui_test.go b/minesweeper_tui_test.go new file mode 100644 index 0000000..4edb9ee --- /dev/null +++ b/minesweeper_tui_test.go @@ -0,0 +1,179 @@ +package main + +import "testing" + +func TestMinesweeperModelRevealViaKey(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.cursor = MinesweeperPos{Row: 4, Col: 4} + + next, _ := m.Update(key("enter")) + m = next.(MinesweeperModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if !m.game.Board[4][4].Revealed { + t.Fatalf("клетка должна была открыться") + } +} + +func TestMinesweeperModelFlagViaKey(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.cursor = MinesweeperPos{Row: 4, Col: 4} + + next, _ := m.Update(key("f")) + m = next.(MinesweeperModel) + if m.isError { + t.Fatalf("неожиданная ошибка: %s", m.message) + } + if !m.game.Board[4][4].Flagged { + t.Fatalf("клетка должна была получить флаг") + } + + next, _ = m.Update(key("f")) + m = next.(MinesweeperModel) + if m.game.Board[4][4].Flagged { + t.Fatalf("флаг должен был сняться") + } +} + +func TestMinesweeperModelCursorMovement(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.cursor = MinesweeperPos{Row: 4, Col: 4} + + next, _ := m.Update(key("up")) + m = next.(MinesweeperModel) + if m.cursor.Row != 3 { + t.Errorf("курсор должен был сдвинуться вверх") + } + next, _ = m.Update(key("right")) + m = next.(MinesweeperModel) + if m.cursor.Col != 5 { + t.Errorf("курсор должен был сдвинуться вправо") + } +} + +func TestMinesweeperModelCursorClampsAtEdges(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.cursor = MinesweeperPos{Row: 0, Col: 0} + + next, _ := m.Update(key("up")) + m = next.(MinesweeperModel) + if m.cursor.Row != 0 { + t.Errorf("курсор не должен был уйти за верхний край") + } + next, _ = m.Update(key("left")) + m = next.(MinesweeperModel) + if m.cursor.Col != 0 { + t.Errorf("курсор не должен был уйти за левый край") + } +} + +func TestMinesweeperModelCannotRevealFlaggedCell(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.cursor = MinesweeperPos{Row: 4, Col: 4} + next, _ := m.Update(key("f")) + m = next.(MinesweeperModel) + + next, _ = m.Update(key("enter")) + m = next.(MinesweeperModel) + if !m.isError { + t.Fatalf("ожидалась ошибка открытия помеченной клетки") + } +} + +func TestMinesweeperModelQReturnsToMenu(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + next, _ := m.Update(key("q")) + m = next.(MinesweeperModel) + if !m.backToMenu { + t.Fatalf("ожидался флаг backToMenu после 'q'") + } + if m.quitting { + t.Fatalf("'q' не должен закрывать всё приложение") + } +} + +func TestMinesweeperModelNewGameAfterResult(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + m.game.Result = &MinesweeperResult{Won: false} + + next, _ := m.Update(key("n")) + m = next.(MinesweeperModel) + if m.game.Result != nil { + t.Errorf("новая партия не должна была начаться с результатом") + } + if m.game.Width != 9 || m.game.Height != 9 || m.game.MineCount != 10 { + t.Errorf("новая партия должна была сохранить параметры поля") + } +} + +func TestMinesweeperModelSupportsAllPresets(t *testing.T) { + presets := []struct{ w, h, mines int }{ + {9, 9, 10}, + {16, 16, 40}, + {30, 16, 99}, + } + for _, p := range presets { + m := NewMinesweeperModel(p.w, p.h, p.mines) + if m.game.Width != p.w || m.game.Height != p.h || m.game.MineCount != p.mines { + t.Errorf("некорректные параметры для пресета %+v: %+v", p, m.game) + } + } +} + +func TestMinesweeperModelChordViaKeyOnRevealedCell(t *testing.T) { + m := NewMinesweeperModel(5, 5, 2) + m.game.minesPlaced = true + m.game.Board[0][1].IsMine = true + m.game.Board[1][0].IsMine = true + for r := 0; r < 5; r++ { + for c := 0; c < 5; c++ { + if m.game.Board[r][c].IsMine { + continue + } + n := 0 + for _, nb := range msNeighbors(msp(r, c)) { + if m.game.inBounds(nb) && m.game.Board[nb.Row][nb.Col].IsMine { + n++ + } + } + m.game.Board[r][c].AdjacentMines = n + } + } + m.game.Board[0][0].Revealed = true + m.game.RevealedCount++ + m.game.ToggleFlag(msp(0, 1)) + m.game.ToggleFlag(msp(1, 0)) + + m.cursor = msp(0, 0) + next, _ := m.Update(key("enter")) + m = next.(MinesweeperModel) + if m.isError { + t.Fatalf("неожиданная ошибка хорды через клавишу: %s", m.message) + } + if !m.game.Board[1][1].Revealed { + t.Errorf("хорда через enter на открытой клетке должна была открыть соседа (1,1)") + } +} + +// TestMinesweeperModelFullAutoPlaythrough прогоняет TUI-модель через +// последовательность нажатий на всех клетках поля, проверяя, что +// путь через Update не паникует и партия завершается (тем или иным +// исходом). +func TestMinesweeperModelFullAutoPlaythrough(t *testing.T) { + m := NewMinesweeperModel(9, 9, 10) + + for r := 0; r < 9 && m.game.Result == nil; r++ { + for c := 0; c < 9 && m.game.Result == nil; c++ { + m.cursor = MinesweeperPos{Row: r, Col: c} + if m.game.Board[r][c].Revealed { + continue + } + next, _ := m.Update(key("enter")) + m = next.(MinesweeperModel) + } + } + if m.game.Result == nil { + t.Fatalf("партия должна была завершиться после перебора всех клеток") + } +} diff --git a/oneohone_bot.go b/oneohone_bot.go index 0b2d920..3f23db0 100644 --- a/oneohone_bot.go +++ b/oneohone_bot.go @@ -8,15 +8,28 @@ var oneOhOneNamePool = []string{ "Мария", "Анна", "Ольга", "Елена", "Наталья", "Светлана", "Ирина", "Татьяна", } +// oneOhOneNamePoolEN — англоязычная версия того же пула. +var oneOhOneNamePoolEN = []string{ + "Mary", "Anna", "Olivia", "Helen", "Natalie", "Sylvia", "Irene", "Diana", +} + +func activeOneOhOneNamePool() []string { + if CurrentLang() == LangEN { + return oneOhOneNamePoolEN + } + return oneOhOneNamePool +} + func pickOneOhOneBotName(used map[string]bool) string { - free := make([]string, 0, len(oneOhOneNamePool)) - for _, name := range oneOhOneNamePool { + pool := activeOneOhOneNamePool() + free := make([]string, 0, len(pool)) + for _, name := range pool { if !used[name] { free = append(free, name) } } if len(free) == 0 { - free = oneOhOneNamePool + free = pool } return free[rand.Intn(len(free))] } diff --git a/oneohone_tui.go b/oneohone_tui.go index 2693d62..450ef23 100644 --- a/oneohone_tui.go +++ b/oneohone_tui.go @@ -32,7 +32,7 @@ func NewOneOhOneModel(numPlayers int) OneOhOneModel { numPlayers = 6 } botNames := newOneOhOneBotNames(numPlayers - 1) - names := append([]string{"Вы"}, botNames...) + names := append([]string{T("common.you")}, botNames...) return OneOhOneModel{ game: NewOneOhOneGame(names, 0), @@ -77,7 +77,7 @@ func (m *OneOhOneModel) setInfo(format string, args ...interface{}) { } func (m *OneOhOneModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -100,10 +100,10 @@ func (m OneOhOneModel) handleBotMove() (tea.Model, tea.Cmd) { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { - m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[idx].Name, err)) return m, nil } - m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[idx].Name) if m.game.Phase == OneOhOnePhasePlay && m.game.CurrentPlayerIdx == m.humanIdx { m.resetSelection() } @@ -156,7 +156,7 @@ func (m OneOhOneModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы сыграли %s.", card) + m.setInfo(T("oneohone.msg.played"), card) if m.game.Phase == OneOhOnePhasePlay { m.resetSelection() } @@ -166,7 +166,7 @@ func (m OneOhOneModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы взяли карту.") + m.setInfo(T("oneohone.msg.drew")) if m.game.Phase == OneOhOnePhasePlay { m.resetSelection() } @@ -180,7 +180,7 @@ func (m OneOhOneModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m OneOhOneModel) renderHand() string { hand := m.currentHand() if len(hand) == 0 { - return dimStyle.Render("(нет карт)") + return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { @@ -201,14 +201,11 @@ func (m OneOhOneModel) renderPlayers() string { marker = "> " } name := p.Name - if i == m.humanIdx { - name += " (вы)" - } status := "" if p.Out { - status = " [выбыл]" + status = T("oneohone.status.out") } - line := fmt.Sprintf("%s%-16s карт: %-2d счёт: %-4d%s", marker, name, len(p.Hand), p.Score, status) + line := fmt.Sprintf(T("oneohone.player_line"), marker, name, len(p.Hand), p.Score, status) if m.game.Phase == OneOhOnePhasePlay && i == m.game.CurrentPlayerIdx { line = turnStyle.Render(line) } @@ -219,34 +216,34 @@ func (m OneOhOneModel) renderPlayers() string { func (m OneOhOneModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== 101 ===")) + fmt.Fprintln(&b, titleStyle.Render(T("oneohone.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) - fmt.Fprintf(&b, "Сброс сверху: %s В колоде: %d карт\n\n", renderCard(m.game.topDiscard()), len(m.game.Stock)) + fmt.Fprintf(&b, T("oneohone.status_line"), renderCard(m.game.topDiscard()), len(m.game.Stock)) if m.game.Phase == OneOhOnePhaseRoundOver { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) if m.game.Result.MatchOver { - fmt.Fprintln(&b, dimStyle.Render("[q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("oneohone.hint.gameover_match"))) } else { - fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("oneohone.hint.gameover"))) } return b.String() } if m.game.CurrentPlayerIdx == m.humanIdx { - fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, T("common.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintln(&b) - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] сыграть карту [d] взять из колоды (если нечем ходить) [q] в меню") + fmt.Fprintln(&b, T("oneohone.hint.turn")) } else { - fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayerIdx].Name) + fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.game.CurrentPlayerIdx].Name) } fmt.Fprintln(&b) @@ -265,14 +262,14 @@ func (m OneOhOneModel) renderResult() string { res := m.game.Result var b strings.Builder if res.Stalemate { - fmt.Fprintln(&b, errorStyle.Render("Раунд зашёл в тупик и принудительно завершён без изменения счёта.")) + fmt.Fprintln(&b, errorStyle.Render(T("oneohone.result.stalemate"))) return strings.TrimRight(b.String(), "\n") } winnerName := m.game.Players[res.Winner].Name - winLine := fmt.Sprintf("%s избавился(лась) от всех карт и выиграл(а) раунд!", winnerName) + winLine := Tf("oneohone.result.win", winnerName) if res.QueenBonus { - winLine += " (бонус за даму!)" + winLine += T("oneohone.result.queen_bonus") } fmt.Fprintln(&b, winStyle.Render(winLine)) fmt.Fprintln(&b) @@ -282,23 +279,23 @@ func (m OneOhOneModel) renderResult() string { note := "" for _, e := range res.Eliminated { if e == i { - note = " — выбыл(а) из партии!" + note = T("oneohone.result.eliminated") } } for _, r := range res.Reset { if r == i { - note = " — ровно 101, счёт обнулён!" + note = T("oneohone.result.reset") } } - fmt.Fprintln(&b, fmt.Sprintf(" %-16s %+d очков за раунд (итого: %d)%s", p.Name, delta, p.Score, note)) + fmt.Fprintln(&b, fmt.Sprintf(T("oneohone.result.delta_line"), p.Name, delta, p.Score, note)) } if res.MatchOver { fmt.Fprintln(&b) if res.NoWinner { - fmt.Fprintln(&b, errorStyle.Render("Партия завершилась без победителя (все выбыли одновременно).")) + fmt.Fprintln(&b, errorStyle.Render(T("oneohone.result.no_winner"))) } else { - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Партия окончена! Победитель: %s", m.game.Players[res.MatchWinner].Name))) + fmt.Fprintln(&b, winStyle.Render(Tf("oneohone.result.match_over", m.game.Players[res.MatchWinner].Name))) } } diff --git a/rules.go b/rules.go index 3fb3ce1..866dff5 100644 --- a/rules.go +++ b/rules.go @@ -1,9 +1,13 @@ package main -// thousandRules — текст правил игры 1000 (базовый вариант строго -// на троих), используется и в TUI-экране "Правила", и при выводе -// через --help. -const thousandRules = `1000 (ТЫСЯЧА) — правила (строго на троих игроков) +// Каждая игра хранит правила в двух вариантах (RU/EN) и отдаёт +// нужный через функцию-геттер в зависимости от текущего языка +// (CurrentLang) — используется и в TUI-экране "Правила", и при +// выводе через --help. + +// --- 1000 (Тысяча) --------------------------------------------- + +const thousandRulesRU = `1000 (ТЫСЯЧА) — правила (строго на троих игроков) У "Тысячи" огромное количество договорённостей и вариантов (бочка, золотой кон, самосвал, тузовый марьяж, игра втёмную, "расписать @@ -74,9 +78,86 @@ const thousandRules = `1000 (ТЫСЯЧА) — правила (строго на q выход в меню ` -// klondikeRules — текст правил пасьянса Косынка (Клондайк), -// используется и в TUI-экране "Правила", и при выводе через --help. -const klondikeRules = `КОСЫНКА (KLONDIKE) — классический пасьянс на одного игрока +const thousandRulesEN = `1000 (THOUSAND) — rules (strictly 3 players) + +"Thousand" has a huge number of house rules and variants (barrel, +golden hand, reset at ±555, ace marriage, blind play, "writing off" +a hand, etc). A basic, most common ruleset is implemented STRICTLY +FOR 3 PLAYERS — the 2-player/4-player versions have noticeably +different kitty/dealer mechanics and are not implemented. + +DECK AND RANK ORDER + 24 cards: 9, J, Q, K, 10, A in each suit. Rank order: + 9 < J < Q < K < 10 < A — yes, the 10 outranks the King but is + weaker than the Ace; that's a quirk specific to this game. + +CARD POINTS + Ace - 11, ten - 10, king - 4, queen - 3, jack - 2, nine - 0. + +DEAL AND BIDDING + Each player is dealt 7 cards, 3 cards go to the kitty. The player + left of the dealer must bid at least 100 (the number of points + they commit to scoring this hand). Going around the table, each + player either raises the bid by at least 5 or passes — once you + pass you're out of this auction. The maximum bid is 120, or 120 + plus the value of a marriage if you hold one. Bidding ends when + only one player remains — they take the kitty and become the + "declarer". + +DISCARD + The declarer takes the kitty (10 cards in hand) and gives one + card to each of the two opponents — after that everyone has 8 + cards. The declarer leads first. + +TRICKS + You must follow suit; if you can't — play trump (if a trump has + been declared this hand); if you have neither — play anything. + The trick is taken by the highest card of the suit led, or the + highest trump if one was played. + +MARRIAGE + If the player leading a NEW trick (except the very first trick of + the hand) holds a King and Queen of the same suit, they may + declare a marriage by leading one of those two cards. That suit + becomes (or changes to) trump for the rest of the hand, and the + marriage's value is immediately added to that player's score for + the hand. Values: spades 40, clubs 60, diamonds 80, hearts 100. + +SCORING + If the declarer's tricks and marriages reach at least their bid, + they score the full bid. If not, the bid is subtracted from their + score. Everyone else simply adds whatever they actually earned, + with no penalty. + +MATCH END + Play continues to 1000 points — first to reach it wins. + +CONTROLS IN THIS VERSION + During bidding: + ↑→/kl or ↓←/hj change the proposed bid by ±5 + enter place the current bid + p pass + During discard and trick play: + ←→/hl select a card in hand + enter play/give the selected card (King/Queen of one suit as + your first card of a trick = declare a marriage) + At any time: + q quit to menu + After a hand ends: + n new hand (if the match isn't over yet) + q quit to menu +` + +func thousandRules() string { + if CurrentLang() == LangEN { + return thousandRulesEN + } + return thousandRulesRU +} + +// --- Косынка (Klondike) ------------------------------------------ + +const klondikeRulesRU = `КОСЫНКА (KLONDIKE) — классический пасьянс на одного игрока Реализован классический "Клондайк" с добором по одной карте за раз и неограниченным числом пересдач колоды из отбоя. @@ -120,9 +201,58 @@ const klondikeRules = `КОСЫНКА (KLONDIKE) — классический п q выход в меню ` -// checkersRules — текст правил русских шашек, используется и в -// TUI-экране "Правила", и при выводе через --help. -const checkersRules = `ШАШКИ (RUSSIAN CHECKERS) — против бота +const klondikeRulesEN = `KLONDIKE SOLITAIRE — classic single-player game + +The classic "Klondike" is implemented with single-card draw and an +unlimited number of stock redeals from the waste pile. + +GOAL + Move all 52 cards onto the four foundations — one per suit, from + Ace up to King. + +LAYOUT + 7 tableau columns (1-7 cards each, only the bottommost dealt card + is face up), a draw stock and a waste pile, 4 foundations (one + per suit). + +MOVES + - You can place a face-up card onto a tableau column if it's one + rank lower and the opposite color (black on red or vice versa). + Only a King can be placed on an empty column. + - You can place a card on a foundation only if it's the next rank + up in that suit, starting from the Ace. + - You can move a properly built (descending, alternating color) + sequence of cards between columns as a whole — not just the top + card. You cannot "cut" such a run and take only part of it. + - When a card underneath a moved run is uncovered, it + automatically flips face up. + - Cards are drawn one at a time from the stock into the waste; + once the stock is empty, the entire waste is flipped back into + the stock for a fresh redeal (no limit on redeals). + - Moving cards back off a foundation is not implemented. + +CONTROLS IN THIS VERSION + A move is two steps: pick a source, then pick where to place it. + 1-7 select a column (or move the selection into it) + 0 select the waste pile (source only) + f move the selected card to its foundation + esc cancel the current selection + d draw a card from the stock (redeals if it's empty) + u undo the last move + n new game (reshuffle) + q quit to menu +` + +func klondikeRules() string { + if CurrentLang() == LangEN { + return klondikeRulesEN + } + return klondikeRulesRU +} + +// --- Шашки (Checkers) -------------------------------------------- + +const checkersRulesRU = `ШАШКИ (RUSSIAN CHECKERS) — против бота Реализованы русские шашки (не международные и не английские) на доске 8х8. Вы всегда играете белыми и ходите первым; бот играет @@ -172,9 +302,201 @@ const checkersRules = `ШАШКИ (RUSSIAN CHECKERS) — против бота q выход в меню ` -// blackjackRules — текст правил Блэкджека, используется и в -// TUI-экране "Правила", и при выводе через --help. -const blackjackRules = `БЛЭКДЖЕК — правила для 1-6 игроков +const checkersRulesEN = `CHECKERS (RUSSIAN RULES) — against a bot + +Russian checkers rules are implemented (not international, not +English draughts) on an 8x8 board. You always play white and move +first; the bot plays black (minimax with alpha-beta pruning). Before +the game starts you can pick a difficulty — "Easy" or "Strong" (the +only difference is the bot's search depth). + +GOAL + Capture all of the opponent's pieces, or leave them with no legal + move at all. + +MOVES + A regular man moves diagonally one square, forward only. A king + (a man that reached the last row) moves diagonally any distance in + any of the four directions. + +CAPTURING + A regular man captures by jumping over an adjacent piece in ANY of + the four diagonal directions, including backward — not just + forward. A king captures with a "flying" jump: it may leap over an + enemy piece from any distance (if the path to it is clear) and + land on any empty square right after it (also any distance). + + Capturing is MANDATORY: if you have any capture available anywhere + on the board, a non-capturing move is illegal. Unlike international + draughts, there is no "must capture the most pieces" rule — any + available capture is fine. If the same piece can capture again + after a capture, it must continue (the turn doesn't pass until the + capture chain ends). A piece that becomes a king mid-chain (by + reaching the last row on a capture) continues that same chain as a + king. + +MATCH END + Whoever still has a legal move when the opponent has none (no + pieces left, or all blocked) wins. If no capture happens for a + long time (60 half-moves), the game is forced to a draw (a + simplification of the official draw rules). + +CONTROLS + ↑↓←→ / hjkl move the cursor around the board + enter/space select a piece (or move the selected piece to the + square under the cursor) + esc cancel the current selection + q quit to menu + After the game ends: + n new game + q quit to menu +` + +func checkersRules() string { + if CurrentLang() == LangEN { + return checkersRulesEN + } + return checkersRulesRU +} + +// --- Шахматы (Chess) ---------------------------------------------- + +const chessRulesRU = `ШАХМАТЫ (CHESS) — против бота + +Реализованы полные правила шахмат, включая рокировку в обе стороны +и взятие на проходе. Вы всегда играете белыми и ходите первым; бот +играет чёрными (минимакс с альфа-бета отсечением). Перед началом +партии можно выбрать уровень сложности — "Простой" или "Сильный" +(разница только в глубине просчёта ходов бота). + +ЦЕЛЬ + Поставить мат королю соперника — либо получить ничью (пат, + недостаточность материала или слишком долгая игра без взятий и + ходов пешками). + +ХОДЫ ФИГУР + Пешка ходит вперёд на одну клетку (на две — с начальной позиции), + бьёт по диагонали на одну клетку вперёд. Конь ходит буквой "Г". + Слон — по диагонали на любое расстояние. Ладья — по горизонтали + или вертикали на любое расстояние. Ферзь совмещает ходы ладьи и + слона. Король — на одну клетку в любом направлении. + +РОКИРОВКА + Король и соответствующая ладья рокируются, если ни один из них ещё + не ходил, все клетки между ними свободны, король не находится под + шахом и не проходит через атакованное поле (включая клетку, на + которую он встаёт). При короткой рокировке король идёт на 2 клетки + к ладье h, ладья становится рядом с ним; при длинной — то же самое + в сторону ладьи a. + +ВЗЯТИЕ НА ПРОХОДЕ + Если пешка соперника только что пошла с начальной позиции сразу на + две клетки и оказалась рядом с вашей пешкой по горизонтали — вы + можете взять её так, как будто она пошла всего на одну клетку. + Это можно сделать только немедленно следующим ходом. + +ПРЕВРАЩЕНИЕ ПЕШКИ + Дойдя до последней горизонтали, пешка автоматически превращается в + ферзя — упрощение: выбор фигуры превращения (ладья/слон/конь) не + реализован, так как подавляющее большинство реальных превращений и + так выбирают именно ферзя. + +ШАХ, МАТ И НИЧЬЯ + Нельзя делать ход, после которого собственный король остаётся под + шахом (в том числе связанной фигурой нельзя открывать шах). Если у + игрока под шахом нет ни одного легального хода — это мат, партия + окончена. Если ходов нет, но короля под шахом не держат — это пат, + ничья. Также реализованы: ничья при недостаточности материала для + мата (например, король против короля) и принудительная ничья при + слишком долгой игре без взятий и ходов пешками (упрощённый аналог + официального правила 75 ходов). Троекратное повторение позиции + НЕ реализовано. + +УПРАВЛЕНИЕ + ↑↓←→ / hjkl передвижение курсора по доске + enter/space выбрать фигуру (или переместить выбранную в клетку + под курсором — для рокировки просто выберите короля + и укажите клетку в двух шагах от него) + esc отменить текущий выбор + q выход в меню + После завершения партии: + n новая партия + q выход в меню +` + +const chessRulesEN = `CHESS — against a bot + +Full chess rules are implemented, including castling both ways and +en passant captures. You always play white and move first; the bot +plays black (minimax with alpha-beta pruning). Before the game +starts you can pick a difficulty — "Easy" or "Strong" (the only +difference is the bot's search depth). + +GOAL + Checkmate the opponent's king — or reach a draw (stalemate, + insufficient material, or too long a game without captures or pawn + moves). + +PIECE MOVES + A pawn moves one square forward (two from its starting square) and + captures one square diagonally forward. A knight moves in an + "L" shape. A bishop moves diagonally any distance. A rook moves + horizontally or vertically any distance. A queen combines rook and + bishop moves. A king moves one square in any direction. + +CASTLING + The king and the relevant rook may castle if neither has moved yet, + all squares between them are empty, the king isn't in check, and it + doesn't pass through an attacked square (including the square it + lands on). In kingside castling the king moves 2 squares toward the + h-rook, which then stands right next to it; queenside castling + works the same way toward the a-rook. + +EN PASSANT + If an enemy pawn has just moved two squares from its starting + position and ended up next to your pawn, you may capture it as if + it had only moved one square. This is only possible on your very + next move. + +PAWN PROMOTION + A pawn reaching the last row automatically promotes to a queen — + a simplification: choosing a different piece (rook/bishop/knight) + isn't implemented, since the vast majority of real promotions pick + a queen anyway. + +CHECK, CHECKMATE AND DRAWS + You may not make a move that leaves your own king in check + (including moving a pinned piece so it would expose the king). + If a player in check has no legal move, that's checkmate and the + game ends. If there's no legal move but the king isn't in check, + that's stalemate — a draw. Also implemented: a draw for + insufficient mating material (e.g., king vs king), and a forced + draw if the game goes on too long without captures or pawn moves + (a simplified version of the official 75-move rule). Threefold + repetition is NOT implemented. + +CONTROLS + ↑↓←→ / hjkl move the cursor around the board + enter/space select a piece (or move the selected piece to the + square under the cursor — for castling, just select + the king and point two squares away from it) + esc cancel the current selection + q quit to menu + After the game ends: + n new game + q quit to menu +` + +func chessRules() string { + if CurrentLang() == LangEN { + return chessRulesEN + } + return chessRulesRU +} + +// --- Блэкджек (Blackjack) ----------------------------------------- + +const blackjackRulesRU = `БЛЭКДЖЕК — правила для 1-6 игроков Каждый игрок играет только против дилера — не друг против друга. Можно играть даже в одиночку: тогда за столом только вы и дилер. @@ -229,9 +551,73 @@ const blackjackRules = `БЛЭКДЖЕК — правила для 1-6 игро q выход в меню ` -// oneOhOneRules — текст правил игры 101, используется и в -// TUI-экране "Правила", и при выводе через --help. -const oneOhOneRules = `101 — правила для 2-6 игроков +const blackjackRulesEN = `BLACKJACK — rules for 1-6 players + +Each player plays only against the dealer — not against each other. +You can even play solo: then it's just you and the dealer. + +GOAL + Get a hand total closer to 21 than the dealer's, without going + over 21. + +CARD VALUES + Ace — 11 or 1 (automatically counted as 1 if 11 would bust). + Face cards (J/Q/K) — 10. Everything else — face value. + +SETUP AND BETTING + At the start of a round every player places a fixed bet. Players + and the dealer are each dealt 2 cards; one of the dealer's cards + is face up, the other hidden until the round ends. A blackjack on + the deal is an Ace plus a 10-value card in the first two cards + (a total of exactly 21 with two cards). + +PLAYER'S TURN + Each player in turn decides to: + - hit — take a card, can repeat until you stand or bust (over 21 + is an instant loss of the bet); + - stand — lock in your current total; + - double down — only on your first two cards: the bet is + doubled, you take exactly one more card, and your turn ends + immediately whether you bust or not. + Splitting a pair into two hands is not supported. + +DEALER'S TURN + Once every player has finished, the dealer reveals the hidden card + and hits while their total is under 17. The dealer stands on any + 17 or higher, including a "soft" 17 with an ace — simpler and more + predictable for a terminal game. + +PAYOUTS + - Blackjack on the deal (and the dealer doesn't have one) — pays + 3:2. + - Both player and dealer have blackjack — push, bet returned. + - Player busts — loses the bet, regardless of what the dealer does + afterward. + - Dealer busts, player doesn't — pays 1:1. + - Player's total beats the dealer's (neither busted) — pays 1:1. + - Totals are equal — push, bet returned. + - Dealer's total is higher — loses the bet. + +CONTROLS IN THIS VERSION + h hit + s stand + d double down (first two cards only) + q quit to menu + After a round ends: + n new round with the same bankroll and bet + q quit to menu +` + +func blackjackRules() string { + if CurrentLang() == LangEN { + return blackjackRulesEN + } + return blackjackRulesRU +} + +// --- 101 ----------------------------------------------------------- + +const oneOhOneRulesRU = `101 — правила для 2-6 игроков У игры "101" существует много вариантов, расходящихся в деталях. Реализован следующий, наиболее часто встречающийся набор правил. @@ -289,10 +675,73 @@ const oneOhOneRules = `101 — правила для 2-6 игроков q выход в меню ` -// durakRules — текст правил Дурака (классический "подкидной" -// вариант в полном виде), используется и в TUI-экране "Правила", и -// при выводе через --help. -const durakRules = `ДУРАК — правила для 2-6 игроков +const oneOhOneRulesEN = `101 — rules for 2-6 players + +There are many variants of "101" that differ in the details. The +following, most commonly seen ruleset is implemented here. + +GOAL + Empty your hand as fast as possible. Whoever does it first wins + the round; everyone else gets penalty points for the cards left in + hand. The match continues until only one player remains — reaching + 101 points or more eliminates you. + +SETUP + 36-card deck (six through ace), same as Durak. The dealer deals 5 + cards to every player except themself — they keep 4, and the + leftover 5th card is placed face up in the middle as the starting + discard. The player to the dealer's left goes first. + +TURN + Play a card of the same suit or the same rank as the top of the + discard pile. If you have no matching card, draw from the stock + one at a time until you draw one that matches (it's played + immediately) or until the stock runs out (then your turn simply + passes). + +SPECIAL CARDS + Ace — skips the next player's turn. + Six — the next player draws 1 card and is skipped. + Seven — the next player draws 2 cards and is skipped. + King of spades — the next player draws 4 cards and is skipped. + All other kings and cards — normal, no effect. + +CARD POINTS (penalty for cards left in hand) + Ace - 11, ten - 10, nine - 0, eight - 8, seven - 7, six - 6, + king - 4, queen - 3, jack - 2. + +END OF A ROUND + The round's winner scores 0 penalty points — and if their last + played card was a queen, they get a -20 point bonus instead (-40 + for the queen of hearts). Everyone else adds up the point value of + the cards left in their hand and adds it to their running score. + +ELIMINATION AND MATCH END + Reaching 101 points or more eliminates you from the match — unless + it's exactly 101, in which case your score resets to 0 (a second + chance). The winner of a round deals the next one. The match + continues until only one player is left — they win. + +CONTROLS IN THIS VERSION + ←→ / hl select a card in hand + enter play the selected card + d draw from the stock (only available if you have no match) + q quit to menu + After a round ends: + n new round (if the match isn't over yet) + q quit to menu +` + +func oneOhOneRules() string { + if CurrentLang() == LangEN { + return oneOhOneRulesEN + } + return oneOhOneRulesRU +} + +// --- Дурак (Durak) -------------------------------------------------- + +const durakRulesRU = `ДУРАК — правила для 2-6 игроков Реализован классический "подкидной" Дурак в полном виде: подкидывать дополнительные карты в раунде может любой игрок за столом, кроме @@ -366,9 +815,89 @@ const durakRules = `ДУРАК — правила для 2-6 игроков q выход в меню ` -// вариант), используется и в TUI-экране "Правила", и при выводе -// через --help. -const tonkRules = `ТОНК (TONK) — правила для 3-4 игроков +const durakRulesEN = `DURAK (RUSSIAN FOOL) — rules for 2-6 players + +A full-featured classic "throw-in" Durak is implemented: any player +at the table except the defender — not just the one who opened the +round — may throw in extra cards. Only the defender ever defends, and +only the defender can take the table. + +SETUP + 36-card deck (six through ace, no 2-5). Everyone is dealt 6 cards. + With 6 players the deck doesn't split evenly — someone may end up + with one fewer card, which is normal and by the rules. The last + card of the deck is revealed as the trump; it stays at the very + bottom of the draw pile and is drawn last. Whoever holds the + lowest trump goes first. + +TURN + The attacker must open the round by throwing in one card. The + defender must beat it with a higher card of the same suit, or any + trump (a trump beats a non-trump card of any rank, but a trump can + only be beaten by a higher trump). + + As soon as a card is beaten, the right to throw in another card + (of a rank already present on the table) passes in turn to each of + the other players except the defender — starting with the attacker + and going around. Each player in turn either throws in a matching + card (forcing the defender to beat it again or take the table) or + passes. Once everyone in a row has declined — or the attack limit + is reached (no more than 6 cards on the table, and no more than the + defender's hand size at the start of the round) — it's called + "beaten": all cards on the table are discarded from play for good. + + If the defender can't (or doesn't want to) beat a card, they take + the whole table into their hand. That's not a loss, just a normal + part of play. + +ROLE ROTATION + After a "beaten" table, the next player after the defender becomes + the new attacker. After a table is taken, the next player after + whoever just took it becomes the new attacker (they skip being + attacker this time). + +DRAWING CARDS + After each round, players draw back up to 6 cards (if the deck has + enough), starting with that round's attacker and going around, with + the defender drawing last. If the table was taken, the player who + took it doesn't draw this time (they already have plenty of cards). + +MATCH END + As soon as a player has no cards in hand and the draw pile is also + empty, they're "safe" and out of play. The game continues for the + rest. The last player left holding cards once everyone else is safe + is the "fool" and loses. If several players empty their hands at + the exact same moment, it's a draw — no fool. + +CONTROLS IN THIS VERSION + When you're opening the round (the mandatory first move): + ←→ / hl select a card in hand + enter throw in the selected card + When you're the defender: + ←→ / hl select a card in hand + enter beat the last unbeaten card with the selected card + t take the whole table into your hand + When it's your turn to throw in (table already beaten): + ←→ / hl select a card in hand + enter throw in another card + p pass (decline to throw in) + At any time: + q quit to menu + After the match ends: + n new match with the same number of players + q quit to menu +` + +func durakRules() string { + if CurrentLang() == LangEN { + return durakRulesEN + } + return durakRulesRU +} + +// --- Тонк (Tonk) ------------------------------------------------------ + +const tonkRulesRU = `ТОНК (TONK) — правила для 3-4 игроков ПОДГОТОВКА Колода 52 карты без джокеров. Каждому раздаётся по 5 карт. @@ -399,6 +928,24 @@ const tonkRules = `ТОНК (TONK) — правила для 3-4 игроков заканчивается победой объявившего — денежные детали см. в разделе "ДЕНЬГИ" ниже. +ДРОП — рискованная альтернатива Тонку + В самом начале своего хода, ДО взятия карты, игрок может + объявить дроп: заявить, что у него, по его мнению, самая низкая + сумма очков на руках за столом. В отличие от Тонка, никакого + порога веса руки не требуется — можно дропнуть с любой рукой, + это чистая ставка на свою интуицию. + - Если дроп верен (рука объявившего строго ниже, чем у всех + остальных) — он забирает банк раунда целиком, как за обычную + победу (без множителя). + - Если дроп неверен (у кого-то сумма равна или ниже) — дроп + "не удался": банк достаётся тому, у кого реально самая + низкая сумма (при нескольких таких игроках — делится + поровну между ними), а объявивший вдобавок платит им штраф + в размере ещё одной ставки (анте) сверху. + Боты тоже иногда идут на дроп, но осторожно: они ориентируются + только на свою руку (не подглядывают в чужие карты) и рискуют, + только если их собственная сумма уже очень мала. + ЗАВЕРШЕНИЕ РАУНДА - Игрок избавился от всех карт -> он забирает банк раунда целиком. - Объявлен Тонк -> объявивший забирает банк, умноженный на @@ -420,11 +967,11 @@ const tonkRules = `ТОНК (TONK) — правила для 3-4 игроков партия; в этой версии капитал может уйти в минус, если ставки превышают остаток средств — отдельного правила банкротства пока нет. - УПРАВЛЕНИЕ В ЭТОЙ ВЕРСИИ Фаза взятия карты: d взять из колоды добора p взять из сброса + k рискнуть дропом (см. раздел "ДРОП" выше) Фаза действия (после взятия карты): ←→ / hl выбор карты в руке space отметить/снять отметку карты для новой комбинации @@ -438,3 +985,647 @@ const tonkRules = `ТОНК (TONK) — правила для 3-4 игроков n новый раунд (счёт сохраняется) q выход ` + +const tonkRulesEN = `TONK — rules for 3-4 players + +SETUP + A 52-card deck, no jokers. Everyone is dealt 5 cards. The rest form + the draw pile. The top card is immediately flipped face up next to + it — that's the start of the discard pile. + +PLAYER'S TURN + 1. Draw a card — either the top of the draw pile or the top of the + discard pile. + 2. You may lay down completed melds on the table: + - a set: 3+ cards of the same rank, different suits + - a sequence: 3+ consecutive cards of the same suit + An ace can be the low card (A-2-3) or the high card (Q-K-A), but + not both at once (you can't "wrap around" the ends). + 3. You may add cards to melds already on the table — yours or + anyone else's. + 4. You must discard one card to end your turn (unless your very + last card went into a meld — then the round ends in a win + without a separate discard). + +CARD POINTS + Ace = 1, jack/queen/king = 10, everything else at face value. + +DECLARING "TONK" + At any point right after drawing (before laying down any melds), a + player may declare Tonk if their hand's point total is at or below + a threshold (5 by default). The round ends immediately in the + declarer's favor — see "MONEY" below for the payout details. + +DROP — a riskier alternative to Tonk + At the very start of your turn, BEFORE drawing, you may declare a + drop: a claim that you believe you hold the lowest point total at + the table. Unlike Tonk, there's no hand-weight threshold required — + you can drop with any hand at all; it's a pure gamble on your own + read of the table. + - If the drop is correct (your hand is strictly lower than + everyone else's) you take the whole pot, same as an ordinary + win (no multiplier). + - If the drop is wrong (someone ties or beats you) the drop + "fails": the pot goes to whoever is actually lowest (split + evenly if there's a tie), and on top of that the dropper pays + them a penalty equal to one more ante. + Bots sometimes drop too, but cautiously: they only look at their + own hand (they never peek at anyone else's cards) and only risk it + when their own total is already very low. + +END OF A ROUND + - A player empties their hand -> they take the whole pot. + - Tonk is declared -> the declarer takes the pot multiplied by the + Tonk multiplier (see below); everyone else pays their share of + the difference on top of the pot. + - The draw pile and discard pile are both exhausted, and no one can + win or declare Tonk -> the round is "dead", the pot is returned + to everyone (each gets their ante back). + +MONEY: THE ANTE AND THE POT + At the start of every round, each player puts a fixed ante into the + shared pot. A normal win (emptying your hand) takes the pot as is. + A win by declaring Tonk takes the pot doubled: everyone else + splits paying the extra amount above the pot — so Tonk is more + rewarding for the winner, but costs the losers more than an + ordinary loss. In a "dead" round nobody wins or loses money — the + antes are simply returned. Each player's bankroll carries over from + round to round while the match continues; in this version a + balance can go negative if the antes exceed what's left — there's + no bankruptcy rule yet. + +CONTROLS IN THIS VERSION + Draw phase: + d draw from the stock + p draw from the discard pile + k risk a drop (see "DROP" above) + Action phase (after drawing): + ←→ / hl select a card in hand + space mark/unmark a card for a new meld + m lay down the marked cards as a meld (set/sequence) + 1-9 add the selected card to table meld #N + t declare Tonk (available if your hand is at or below the + threshold) + enter discard the selected card and end your turn + At any time: + q quit + After a round ends: + n new round (bankroll carries over) + q quit +` + +func tonkRules() string { + if CurrentLang() == LangEN { + return tonkRulesEN + } + return tonkRulesRU +} + +// --- Трупные батончики (Corpse-Starch Box) ------------------------ + +const corpseStarchRulesRU = `ТРУПНЫЕ БАТОНЧИКИ (CORPSE-STARCH BOX) + +Инкрементальная (кликер/idle) игра в духе Candy Box 2 — в антураже +мрачного далёкого будущего. Никаких ботов и соперников: вы просто +копите трупный крахмал и постепенно открываете новые механики по +мере накопления ресурса. + +СОХРАНЕНИЕ + В отличие от остальных игр коллекции, эта партия сохраняется на + диск: автоматически каждые 15 секунд и при выходе в меню (клавиша + q). При следующем запуске партия загрузится с того же места, а + сервиторы честно "домолотят" трупный крахмал за всё время, пока + вы отсутствовали, — как будто вы всё это время не отходили от + экрана. Чтобы начать заново, достаточно удалить файл сохранения + (обычно ~/.config/go-games-collection/corpsestarch_save.json). + +ЦЕЛЬ + Формальной цели или условия победы нет — это чистый инкрементальный + опыт: копите ресурс, открывайте разделы, экспериментируйте. + +ОСНОВНОЙ РЕСУРС + Трупный крахмал добывается вручную (реквизиция) и, после открытия + раздела, ещё и пассивно — в реальном времени, пока партия открыта + на экране (даже если вы ничего не нажимаете). + +РАЗДЕЛЫ, ОТКРЫВАЮЩИЕСЯ ПО МЕРЕ НАКОПЛЕНИЯ + - Сервиторы — покупка юнитов пассивного дохода (сервочерепа, + сервитора-подёнщика, когитаторного комплекса); каждая следующая + покупка одного тира дороже предыдущей. + - Муниторум — заточка цепного меча повышает доход от ручной + реквизиции. + - Отстойник — можно окунуть в него цепной меч: случайный исход + (бонус крахмала, временное благословение духа машины, + ничего, либо скверна с потерей части запаса). + - Зачистки — отправить отряд на одну из миссий на реальное время + (от 20 секунд до нескольких минут); по завершении можно забрать + награду. + +УПРАВЛЕНИЕ + 1-9 выбрать пронумерованное действие (список меняется по мере + открытия новых разделов — номера могут "переезжать") + q сохранить партию и выйти в меню +` + +const corpseStarchRulesEN = `CORPSE-STARCH BOX + +An incremental (clicker/idle) game in the spirit of Candy Box 2 — +set in a grim, dark far future. No bots or opponents: you simply +gather corpse starch and gradually unlock new mechanics as your +stockpile grows. + +SAVING + Unlike the other games in this collection, this game saves to + disk: automatically every 15 seconds and whenever you quit to the + menu (the q key). Next time you start it, your game picks up right + where you left off, and your servitors will honestly "catch up" on + the corpse starch they gathered while you were away — as if you'd + never left the screen. To start fresh, just delete the save file + (usually ~/.config/go-games-collection/corpsestarch_save.json). + +GOAL + There's no formal goal or win condition — it's a pure incremental + experience: gather resources, unlock sections, experiment. + +MAIN RESOURCE + Corpse starch is gathered manually (requisition) and, once that + section unlocks, also passively in real time while the game screen + is open (even if you press nothing). + +SECTIONS THAT UNLOCK AS YOU GATHER MORE + - Servitors — buy passive-income units (servo-skull, menial + servitor, cogitator array); each further purchase of a tier + costs more than the last. + - Munitorum — sharpening your chainsword raises your manual + requisition yield. + - The Sump — dip your chainsword into it for a random outcome + (a starch bonus, a temporary machine-spirit blessing, nothing at + all, or taint that costs you part of your stockpile). + - Purges — send a squad on one of several missions that take real + time (from 20 seconds to a few minutes); collect the reward once + it's done. + +CONTROLS + 1-9 choose a numbered action (the list grows as you unlock more + sections — numbers may shift around) + q save the game and quit to menu +` + +func corpseStarchRules() string { + if CurrentLang() == LangEN { + return corpseStarchRulesEN + } + return corpseStarchRulesRU +} + +// --- Го (Go) ------------------------------------------------------- + +const goRulesRU = `ГО (GO) — против бота + +Реализованы базовые правила Го: свободы групп камней, взятие групп +без единой свободы, запрет самоубийственного хода (кроме случая, +когда сам ход берёт вражеские камни и получает свободу), простое +правило ко. Перед началом партии можно выбрать размер доски (9x9, +13x13 или 19x19) и уровень сложности бота. Вы всегда играете +чёрными и ходите первым; бот играет белыми. + +О СИЛЕ БОТА: честная сильная игра в Го требует Monte-Carlo поиска +по дереву партии или нейросетевой оценки позиции (как в AlphaGo) — +минимакс здесь почти бесполезен из-за огромного ветвления. Все три +уровня сложности — эвристические, БЕЗ поиска вперёд по дереву +партии, и играют существенно слабее сильного человека даже на +"Сильном" уровне. + +ЦЕЛЬ + Окружить своими камнями больше территории (пустых точек) и camней, + чем соперник, к концу партии. + +ХОДЫ + Игроки по очереди ставят по одному камню на свободную точку + пересечения линий (не в клетку, как в шашках/шахматах). Можно + пасовать вместо хода. + +СВОБОДЫ И ВЗЯТИЕ + У связной группы камней одного цвета есть "свободы" — пустые + точки, прилегающие к ней. Если после хода соперника у группы не + остаётся ни одной свободы, вся группа снимается с доски (берётся). + Нельзя ставить камень так, чтобы его собственная группа осталась + без единой свободы (самоубийственный ход) — ЕСЛИ только этот же + ход не берёт вражескую группу, тем самым освобождая точку. + +ПРАВИЛО КО + Нельзя немедленно восстановить позицию, которая была на доске до + хода соперника, — это предотвращает бесконечный обмен взятием + одной и той же точки туда-обратно. Достаточно сходить в любое + другое место — на следующий ход взятие снова станет возможным. + Более сложное правило "тройного повторения позиции" (супер-ко) не + реализовано. + +ОКОНЧАНИЕ ПАРТИИ И ПОДСЧЁТ ОЧКОВ + Два паса подряд заканчивают партию. Счёт считается по КИТАЙСКИМ + (площадным) правилам: очки = число своих камней на доске + число + пустых точек, граничащих ИСКЛЮЧИТЕЛЬНО с этим цветом (точки, + граничащие с обоими цветами, нейтральны и не достаются никому). + Белые получают коми (компенсацию за то, что чёрные ходят первыми) + — 7.5 очка при любом размере доски. + + ВАЖНОЕ УПРОЩЕНИЕ: фаза "удаления мёртвых камней" перед подсчётом + не реализована — партия оценивается по камням, буквально + оставшимся на доске в момент второго паса. Если камень фактически + окружён, но не взят по-настоящему, он засчитывается как живой + своему цвету. Поэтому перед двумя пасами стоит реально взять все + безнадёжные камни соперника, а не полагаться на то, что они + "и так мертвы". + +УПРАВЛЕНИЕ + ↑↓←→ / hjkl передвижение курсора по доске + enter/space поставить камень в клетку под курсором + p пасовать + q выход в меню + После завершения партии: + n новая партия (тот же размер доски и сложность) + q выход в меню +` + +const goRulesEN = `GO — against a bot + +Basic Go rules are implemented: group liberties, capturing groups +with no liberties left, forbidding suicidal moves (unless the move +itself captures enemy stones and thereby gains a liberty), and the +simple ko rule. Before the game starts you can pick the board size +(9x9, 13x13, or 19x19) and the bot's difficulty. You always play +black and move first; the bot plays white. + +ON BOT STRENGTH: genuinely strong Go play requires Monte Carlo tree +search or neural-network position evaluation (as in AlphaGo) — +minimax is nearly useless here because of the enormous branching +factor. All three difficulty levels are heuristic-based, WITHOUT any +lookahead search through the game tree, and play noticeably weaker +than a strong human even on "Strong" difficulty. + +GOAL + Surround more territory (empty points) and stones with your color + than your opponent by the end of the game. + +MOVES + Players take turns placing one stone on an empty line intersection + (not in a square, unlike checkers/chess). You may pass instead of + playing. + +LIBERTIES AND CAPTURING + A connected group of same-colored stones has "liberties" — empty + points adjacent to it. If a group is left with zero liberties after + the opponent's move, the whole group is removed from the board + (captured). You may not place a stone so that its own group ends up + with zero liberties (a suicidal move) — UNLESS that same move + captures an enemy group, thereby freeing up a liberty. + +THE KO RULE + You may not immediately recreate the board position that existed + before your opponent's last move — this prevents an endless back- + and-forth recapture of the same point. Playing anywhere else first + is enough; the point becomes capturable again on your next turn. + The more elaborate "positional superko" (triple repetition) rule is + NOT implemented. + +GAME END AND SCORING + Two passes in a row end the game. Scoring uses CHINESE (area) + rules: score = number of your stones on the board + number of empty + points bordering ONLY your color (points touching both colors are + neutral and go to neither). White receives komi (compensation for + black moving first) — 7.5 points regardless of board size. + + IMPORTANT SIMPLIFICATION: the "dead stone removal" phase before + scoring isn't implemented — the game is scored based on whatever is + literally still on the board at the second pass. If a stone is + effectively surrounded but never actually captured, it still counts + as alive for its color. So before passing twice, make sure to + actually capture any hopeless opposing stones rather than relying + on them being "obviously dead." + +CONTROLS + ↑↓←→ / hjkl move the cursor around the board + enter/space place a stone on the square under the cursor + p pass + q quit to menu + After the game ends: + n new game (same board size and difficulty) + q quit to menu +` + +func goRules() string { + if CurrentLang() == LangEN { + return goRulesEN + } + return goRulesRU +} + +// --- Сапёр (Minesweeper) -------------------------------------------- + +const minesweeperRulesRU = `САПЁР (MINESWEEPER) + +Классический Сапёр без ботов и соперников. Перед началом партии +можно выбрать один из трёх классических пресетов размера поля. + +ЦЕЛЬ + Открыть все клетки, под которыми нет мины, ни разу не открыв + клетку с миной. + +ПРЕСЕТЫ ПОЛЯ + Новичок — 9x9, 10 мин + Любитель — 16x16, 40 мин + Эксперт — 30x16, 99 мин + +ХОДЫ + Открытая клетка либо взрывается (если там мина — партия окончена + поражением), либо показывает число мин среди восьми соседних + клеток. Если соседних мин ноль — открытие автоматически + распространяется на все соседние клетки каскадом, и так далее, + пока не наткнётся на клетки с ненулевым числом. + + Первый ход в партии ГАРАНТИРОВАННО безопасен: мины расставляются + только после него, и координаты первой открытой клетки (вместе со + всеми её соседями) исключаются из расстановки — это чуть щедрее + классического правила (там исключается только сама клетка), зато + первый ход почти всегда сразу открывает небольшую область. + + Клетку можно пометить флагом (подозрение на мину) — это просто + визуальная пометка для себя, она не влияет на игру и не открывает + клетку; помеченную клетку сначала нужно снять с пометки, чтобы + открыть. + + ХОРДА: если нажать на уже открытую клетку с числом, а рядом с ней + уже стоит ровно столько флагов, сколько показывает это число, все + остальные закрытые соседи откроются автоматически — это ускоряет + игру, знакомая опытным игрокам возможность. Если хотя бы один флаг + стоит неверно (не на настоящей мине), хорда может привести к + проигрышу — как и в оригинальной игре. + +ПОБЕДА И ПОРАЖЕНИЕ + Победа наступает, когда открыты все клетки, кроме тех, где мины. + Поражение — как только открыта клетка с миной (при этом + раскрываются все мины на поле, чтобы было видно, где они были). + +УПРАВЛЕНИЕ + ↑↓←→ / hjkl передвижение курсора по полю + enter/space открыть клетку под курсором (или хорда, если она + уже открыта) + f поставить/снять флаг на клетке под курсором + q выход в меню + После завершения партии: + n новая партия (тот же размер поля) + q выход в меню +` + +const minesweeperRulesEN = `MINESWEEPER + +Classic Minesweeper, no bots or opponents. Before the game starts +you can pick one of three classic field size presets. + +GOAL + Reveal every cell that isn't a mine, without ever revealing a + mined cell. + +FIELD PRESETS + Beginner — 9x9, 10 mines + Intermediate — 16x16, 40 mines + Expert — 30x16, 99 mines + +MOVES + Revealing a cell either blows up (if it's a mine — game over) or + shows how many of its eight neighbors are mines. If that count is + zero, the reveal automatically cascades to all neighboring cells, + and so on, until it reaches cells with a nonzero count. + + Your very first move is GUARANTEED safe: mines are placed only + after it, and the coordinates of that first cell (along with all + its neighbors) are excluded from mine placement — a bit more + generous than the classic rule (which only excludes the cell + itself), so the first move almost always opens up a small area + right away. + + You can flag a cell (mark it as a suspected mine) — this is purely + a visual note to yourself, it doesn't affect the game and doesn't + reveal the cell; a flagged cell must be unflagged first before it + can be revealed. + + CHORDING: pressing an already-revealed numbered cell, when exactly + that many flags are already placed around it, automatically reveals + all of its remaining unflagged neighbors — a speed-up trick well + known to experienced players. If even one of those flags is wrong + (not actually on a mine), chording can cost you the game — just + like in the original. + +WINNING AND LOSING + You win once every non-mine cell has been revealed. You lose the + instant a mined cell is revealed (all mines are then shown, so you + can see where they were). + +CONTROLS + ↑↓←→ / hjkl move the cursor around the field + enter/space reveal the cell under the cursor (or chord, if it's + already revealed) + f toggle a flag on the cell under the cursor + q quit to menu + After the game ends: + n new game (same field size) + q quit to menu +` + +func minesweeperRules() string { + if CurrentLang() == LangEN { + return minesweeperRulesEN + } + return minesweeperRulesRU +} + +// --- Странник (Wayfarer) -------------------------------------------- + +const wayfarerRulesRU = `СТРАННИК (WAYFARER) + +Текстовая космоторговля, вдохновлённая классической Elite (David +Braben, Ian Bell), но не являющаяся её копией: собственный алгоритм +процедурной галактики, свои названия и тексты, свои квесты. +Настоящего 3D-полёта в реальном времени нет — это сознательное +решение (честный 3D-рендер в терминале потребовал бы отдельного +движка сопоставимой с самой игрой сложности); вместо этого перелёты, +опасные встречи и сюжетные квесты разрешаются текстом с выбором. + +СОХРАНЕНИЕ + Как и "Трупные батончики", это открытая карьера без фиксированного + конца — партия сохраняется на диск автоматически при выходе в + меню. Гибель корабля БЕЗ спасательной капсулы необратимо удаляет + сохранение — карьера начинается с чистого листа (как перманентная + смерть в оригинальной Elite, только чуть смягчённая: капсула даёт + шанс выжить). + +ГАЛАКТИКА + 4 галактики по 128 систем в каждой — итого 512 звёздных систем, + сгенерированных детерминированно (одна и та же система при новой + карьере всегда одна и та же). У каждой системы своё правительство + (от анархии до корпоративного государства — влияет на опасность + перелётов и строгость досмотра груза), уровень технологий, + промышленность/сельское хозяйство и благосостояние — это определяет + цены на рынке. + + ПАСХАЛКА: как и в оригинальной Elite, где галактику можно было + поменять, отредактировав числа генератора в коде игры, — здесь + можно получить совершенно другую галактику, не трогая код + коллекции. Создайте файл .env рядом с исполняемым файлом (или в + текущей рабочей директории) со строкой: + WAYFARER_GALAXY_SEED=12345 + Любое целое число — и все 512 систем, их названия, правительства + и характеристики будут другими. Без файла .env используется + обычная (стандартная) галактика. + +ТОРГОВЛЯ + 10 видов товаров, включая два незаконных (контрабанда, оружие) — + их проверяют патрули в системах со строгим правительством. Цена + зависит от типа экономики системы: аграрные дешевле продают + продовольствие, промышленные — технику, и так далее. Пока товар + лежит в трюме, рынок показывает среднюю цену, по которой он был + куплен, — удобно сравнить с текущей ценой перед продажей. + +ПЕРЕЛЁТЫ И ОПАСНЫЕ ВСТРЕЧИ + Прыжок в пределах галактики тратит топливо пропорционально + расстоянию. Чем опаснее правительство системы назначения, тем выше + шанс случайной встречи: пиратская засада (пошаговый бой), досмотр + патруля (незаконный груз могут конфисковать), сигнал бедствия + (обычно — честная награда за помощь, изредка — ловушка) или + обломки корабля (случайная находка). Межгалактический прыжок в + следующую галактику требует почти полного бака и переносит в + случайную систему. + +ПОШАГОВЫЙ БОЙ + Каждый раунд — один выбор: атаковать, попытаться сбежать или (если + противник вообще готов договариваться) попытаться откупиться + переговорами. Щит поглощает урон первым, затем корпус. Без + спасательной капсулы гибель корабля необратимо заканчивает карьеру. + +КВЕСТЫ + 8 полноценных сюжетных линий с ветвлениями и разными исходами — + от эвакуации колонистов перед вспышкой сверхновой до находки + артефакта предтеч. Задания не выбираются свободно из списка — они + сами предлагаются со случайной вероятностью при спокойном прибытии + в систему (если сейчас нет активного задания и остались + невыполненные). Можно согласиться или отказаться; отказ ничем не + грозит — то же (или другое) задание может предложиться снова + позже. Одновременно активно только одно задание; выбор в его ходе + влияет на награду, здоровье корабля и иногда — на груз. + +СНАРЯЖЕНИЕ И РЕМОНТ + На верфи можно дозаправиться, починить корпус и один раз + приобрести каждый апгрейд: топливный скиммер, расширение трюма, + усиление щита, спасательную капсулу, улучшенный лазер. + +УПРАВЛЕНИЕ + На станции (хаб): + 1 рынок 2 верфь 3 перелёт 4 квесты + q сохранить карьеру и выйти в меню + Рынок/верфь/перелёт/квесты: + ↑↓ / kj выбор пункта + b / s купить/продать 1 единицу (на рынке) + B / S купить максимум / продать всё (на рынке) + enter подтвердить выбор + q/esc назад в хаб + В бою: + 1 атаковать 2 бежать 3 договориться (если доступно) +` + +const wayfarerRulesEN = `WAYFARER + +A text-based space trading game inspired by the classic Elite (David +Braben, Ian Bell), but not a copy of it: its own procedural galaxy +algorithm, its own names and text, its own quests. There's no real- +time 3D flight — that's a deliberate choice (an honest 3D renderer in +a terminal would need a separate engine about as complex as the game +itself); instead, travel, dangerous encounters, and story quests are +all resolved through text with choices. + +SAVING + Like Corpse-Starch Box, this is an open-ended career with no fixed + end — the game saves to disk automatically when you quit to the + menu. Losing your ship WITHOUT an escape pod permanently deletes + the save — your career starts over from scratch (like permadeath in + the original Elite, just slightly softened: the pod gives you a + chance to survive). + +THE GALAXY + 4 galaxies of 128 systems each — 512 star systems in total, + generated deterministically (the same system is always the same + system on a new career). Each system has its own government (from + anarchy to corporate state — affects travel danger and how strict + cargo inspections are), tech level, industry/agriculture balance, + and wealth — these determine market prices. + + EASTER EGG: just like the original Elite, where you could get a + whole new galaxy by editing the generator's seed numbers in the + game's source code — here you can get an entirely different galaxy + without touching the collection's code at all. Create a .env file + next to the executable (or in the current working directory) with + the line: + WAYFARER_GALAXY_SEED=12345 + Any integer works — and all 512 systems, their names, governments, + and stats will come out different. Without a .env file, the + standard (default) galaxy is used. + +TRADING + 10 types of goods, including two illegal ones (contraband, firearms) + — patrols check for them in systems with strict governments. Prices + depend on the system's economy: agricultural systems sell food + cheaply, industrial ones sell machinery cheaply, and so on. While a + good is sitting in your hold, the market shows the average price + you paid for it — handy for comparing against the current price + before selling. + +TRAVEL AND DANGEROUS ENCOUNTERS + A jump within the galaxy costs fuel proportional to distance. The + more dangerous the destination system's government, the higher the + chance of a random encounter: a pirate ambush (turn-based combat), a + patrol inspection (illegal cargo may be confiscated), a distress + signal (usually a fair reward for helping, occasionally a trap), or + a derelict ship (a random find). An intergalactic jump to the next + galaxy needs a nearly full tank and drops you into a random system. + +TURN-BASED COMBAT + Each round is one choice: attack, try to flee, or (if the opponent + is willing to talk at all) try to negotiate your way out. Shields + absorb damage first, then the hull. Without an escape pod, losing + your ship permanently ends your career. + +QUESTS + 8 full story arcs with branching choices and different outcomes — + from evacuating colonists ahead of a supernova to uncovering a + precursor artifact. Quests aren't freely picked from a list — they + offer themselves at random when you arrive safely at a system (as + long as you have no active quest and some are still unfinished). + You can accept or decline; declining has no downside — the same + (or another) quest may be offered again later. Only one quest can + be active at a time; your choices along the way affect the reward, + your ship's health, and sometimes your cargo. + +EQUIPMENT AND REPAIRS + At the shipyard you can refuel, repair the hull, and buy each + upgrade once: a fuel scoop, a cargo bay expansion, a shield + booster, an escape pod, and an improved laser. + +CONTROLS + At the station (hub): + 1 market 2 shipyard 3 travel 4 quests + q save career and quit to menu + Market/shipyard/travel/quests: + ↑↓ / kj select an item + b / s buy/sell 1 unit (market) + B / S buy max / sell all (market) + enter confirm selection + q/esc back to hub + In combat: + 1 attack 2 flee 3 negotiate (if available) +` + +func wayfarerRules() string { + if CurrentLang() == LangEN { + return wayfarerRulesEN + } + return wayfarerRulesRU +} diff --git a/thousand_bot.go b/thousand_bot.go index 795e8cf..eab814b 100644 --- a/thousand_bot.go +++ b/thousand_bot.go @@ -8,15 +8,28 @@ var thousandNamePool = []string{ "Виктор", "Роман", "Максим", "Артём", "Кирилл", "Егор", "Денис", "Павел", } +// thousandNamePoolEN — англоязычная версия того же пула. +var thousandNamePoolEN = []string{ + "Victor", "Roman", "Max", "Arthur", "Cyril", "Eric", "Dennis", "Paul", +} + +func activeThousandNamePool() []string { + if CurrentLang() == LangEN { + return thousandNamePoolEN + } + return thousandNamePool +} + func pickThousandBotName(used map[string]bool) string { - free := make([]string, 0, len(thousandNamePool)) - for _, name := range thousandNamePool { + pool := activeThousandNamePool() + free := make([]string, 0, len(pool)) + for _, name := range pool { if !used[name] { free = append(free, name) } } if len(free) == 0 { - free = thousandNamePool + free = pool } return free[rand.Intn(len(free))] } diff --git a/thousand_tui.go b/thousand_tui.go index 13302b8..be5cfaa 100644 --- a/thousand_tui.go +++ b/thousand_tui.go @@ -27,7 +27,7 @@ type ThousandModel struct { // 2 бота со случайными именами). func NewThousandModel() ThousandModel { botNames := newThousandBotNames(2) - names := append([]string{"Вы"}, botNames...) + names := append([]string{T("common.you")}, botNames...) return ThousandModel{ game: NewThousandGame(names, 0), humanIdx: 0, @@ -84,7 +84,7 @@ func (m *ThousandModel) setInfo(format string, args ...interface{}) { } func (m *ThousandModel) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -107,10 +107,10 @@ func (m ThousandModel) handleBotMove() (tea.Model, tea.Cmd) { return m, nil } if err := m.bot.PlayFullTurn(m.game); err != nil { - m.setError(fmt.Errorf("бот %s: %w", m.game.Players[actor].Name, err)) + m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[actor].Name, err)) return m, nil } - m.setInfo("%s сходил(а).", m.game.Players[actor].Name) + m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[actor].Name) if m.game.Phase != ThousandPhaseHandOver && m.actorForPhase() == m.humanIdx { m.resetSelection() m.bidAmount = m.game.HighBid + 5 @@ -175,7 +175,7 @@ func (m ThousandModel) handleBiddingKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы поставили %d.", m.bidAmount) + m.setInfo(T("thousand.msg.bid"), m.bidAmount) if m.game.Phase != ThousandPhaseHandOver { m.bidAmount = m.game.HighBid + 5 } @@ -185,7 +185,7 @@ func (m ThousandModel) handleBiddingKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы пасовали.") + m.setInfo(T("thousand.msg.passed")) return m, m.maybeScheduleBot() } return m, nil @@ -211,7 +211,7 @@ func (m ThousandModel) handleDiscardKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы отдали %s.", card) + m.setInfo(T("thousand.msg.gave_card"), card) m.resetSelection() if m.game.Phase != ThousandPhaseDiscard { return m, m.maybeScheduleBot() @@ -240,7 +240,7 @@ func (m ThousandModel) handleTrickKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы сыграли %s.", card) + m.setInfo(T("thousand.msg.played"), card) if m.game.Phase != ThousandPhaseHandOver { m.resetSelection() } @@ -254,7 +254,7 @@ func (m ThousandModel) handleTrickKey(key string) (tea.Model, tea.Cmd) { func (m ThousandModel) renderHand() string { hand := m.currentHand() if len(hand) == 0 { - return dimStyle.Render("(нет карт)") + return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { @@ -275,14 +275,11 @@ func (m ThousandModel) renderPlayers() string { marker = "> " } name := p.Name - if i == m.humanIdx { - name += " (вы)" - } role := "" if m.game.Phase != ThousandPhaseBidding && i == m.game.DeclarerIdx { - role = " [торг]" + role = T("thousand.declarer_tag") } - line := fmt.Sprintf("%s%-14s карт: %-2d очки за раздачу: %-4d счёт: %-5d%s", + line := fmt.Sprintf(T("thousand.player_line"), marker, name, len(p.Hand), p.HandPoints, p.Score, role) if m.game.Phase != ThousandPhaseHandOver && i == m.actorForPhase() { line = turnStyle.Render(line) @@ -294,7 +291,7 @@ func (m ThousandModel) renderPlayers() string { func (m ThousandModel) renderTrick() string { if len(m.game.CurrentTrick) == 0 { - return dimStyle.Render("(взятка ещё не начата)") + return dimStyle.Render(T("thousand.trick_not_started")) } parts := make([]string, len(m.game.CurrentTrick)) for i, play := range m.game.CurrentTrick { @@ -305,11 +302,11 @@ func (m ThousandModel) renderTrick() string { func (m ThousandModel) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== 1000 ===")) + fmt.Fprintln(&b, titleStyle.Render(T("thousand.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) @@ -318,44 +315,44 @@ func (m ThousandModel) View() string { fmt.Fprintln(&b, m.renderResult()) fmt.Fprintln(&b) if m.game.Result.MatchOver { - fmt.Fprintln(&b, dimStyle.Render("[q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.gameover_match"))) } else { - fmt.Fprintln(&b, dimStyle.Render("[n] новая раздача [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.gameover"))) } return b.String() } switch m.game.Phase { case ThousandPhaseBidding: - fmt.Fprintf(&b, "Текущая максимальная ставка: %d (у %s)\n\n", m.game.HighBid, m.bidderName()) + fmt.Fprintf(&b, T("thousand.bidding_status"), m.game.HighBid, m.bidderName()) case ThousandPhaseDiscard, ThousandPhaseTrick: - trumpStr := "ещё не назначен" + trumpStr := T("thousand.trump_unset") if m.game.TrumpSet { trumpStr = suitName(m.game.TrumpSuit) } - fmt.Fprintf(&b, "Ставка: %d (у %s) Козырь: %s\n", m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr) + fmt.Fprintf(&b, T("thousand.play_status"), m.game.FinalBid, m.game.Players[m.game.DeclarerIdx].Name, trumpStr) if m.game.Phase == ThousandPhaseTrick { - fmt.Fprintln(&b, "Взятка:", m.renderTrick()) + fmt.Fprintln(&b, T("thousand.trick_label"), m.renderTrick()) } fmt.Fprintln(&b) } if m.actorForPhase() == m.humanIdx { - fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, T("thousand.your_hand")) fmt.Fprintln(&b, m.renderHand()) fmt.Fprintln(&b) switch m.game.Phase { case ThousandPhaseBidding: - fmt.Fprintf(&b, "[↑→/kl] +5 [↓←/hj] -5 [enter] поставить %d [p] пас\n", m.bidAmount) + fmt.Fprintf(&b, T("thousand.hint.bid"), m.bidAmount) case ThousandPhaseDiscard: - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] отдать карту следующему сопернику") + fmt.Fprintln(&b, T("thousand.hint.discard")) case ThousandPhaseTrick: - fmt.Fprintln(&b, "[←→/hl] выбор карты [enter] сыграть карту (король/дама масти = объявить марьяж)") + fmt.Fprintln(&b, T("thousand.hint.trick")) } - fmt.Fprintln(&b, dimStyle.Render("[q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("thousand.hint.menu"))) } else { - fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.actorForPhase()].Name) + fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.actorForPhase()].Name) } fmt.Fprintln(&b) @@ -372,7 +369,7 @@ func (m ThousandModel) View() string { func (m ThousandModel) bidderName() string { if m.game.HighBidderIdx < 0 { - return "никого — торги ещё не начались" + return T("thousand.no_bidder") } return m.game.Players[m.game.HighBidderIdx].Name } @@ -380,13 +377,13 @@ func (m ThousandModel) bidderName() string { func suitName(s Suit) string { switch s { case Clubs: - return "трефы" + return T("thousand.suit.clubs") case Diamonds: - return "бубны" + return T("thousand.suit.diamonds") case Hearts: - return "червы" + return T("thousand.suit.hearts") case Spades: - return "пики" + return T("thousand.suit.spades") } return "?" } @@ -396,17 +393,17 @@ func (m ThousandModel) renderResult() string { var b strings.Builder declarerName := m.game.Players[res.DeclarerIdx].Name if res.Made { - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s выполнил(а) ставку %d!", declarerName, res.FinalBid))) + fmt.Fprintln(&b, winStyle.Render(Tf("thousand.result.made", declarerName, res.FinalBid))) } else { - fmt.Fprintln(&b, errorStyle.Render(fmt.Sprintf("%s не выполнил(а) ставку %d.", declarerName, res.FinalBid))) + fmt.Fprintln(&b, errorStyle.Render(Tf("thousand.result.failed", declarerName, res.FinalBid))) } fmt.Fprintln(&b) for i, p := range m.game.Players { - fmt.Fprintln(&b, fmt.Sprintf(" %-14s %+d очков за раздачу (итого: %d)", p.Name, res.ScoreDeltas[i], p.Score)) + fmt.Fprintln(&b, fmt.Sprintf(T("thousand.result.delta_line"), p.Name, res.ScoreDeltas[i], p.Score)) } if res.MatchOver { fmt.Fprintln(&b) - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("Партия окончена! Победитель: %s", m.game.Players[res.MatchWinner].Name))) + fmt.Fprintln(&b, winStyle.Render(Tf("thousand.result.match_over", m.game.Players[res.MatchWinner].Name))) } return strings.TrimRight(b.String(), "\n") } diff --git a/tonk b/tonk new file mode 100755 index 0000000..5b733e0 Binary files /dev/null and b/tonk differ diff --git a/translations.go b/translations.go new file mode 100644 index 0000000..1ff4e69 --- /dev/null +++ b/translations.go @@ -0,0 +1,406 @@ +package main + +// translations — таблица переводов "ключ -> {ru, en}". Заполняется +// по мере перевода каждого файла интерфейса; см. i18n.go для +// функций T/Tf, которые ей пользуются. +var translations = map[string]translationEntry{ + // --- Экран выбора языка --- + "lang.title": {"=== ВЫБОР ЯЗЫКА ===", "=== CHOOSE LANGUAGE ==="}, + "lang.ru": {"Русский", "Русский (Russian)"}, + "lang.en": {"English", "English"}, + "lang.hint": {"[↑↓/kj] выбор [enter] подтвердить", "[↑↓/kj] select [enter] confirm"}, + + // --- Общее меню --- + "app.subtitle": {"Коллекция консольных игр на Go", "A console collection of card and board games in Go"}, + "menu.hint": {"[↑↓/kj] выбор [enter] подтвердить [q] выход", "[↑↓/kj] select [enter] confirm [q] quit"}, + "menu.rules": {"Правила", "Rules"}, + "menu.rules_desc": {"Прочитать правила выбранной игры", "Read the rules of a chosen game"}, + "menu.language": {"Язык / Language", "Язык / Language"}, + "menu.language_desc": {"Сменить язык интерфейса", "Change interface language"}, + "menu.quit": {"Выход", "Quit"}, + "menu.quit_desc": {"Закрыть коллекцию игр", "Close the game collection"}, + + // --- Реестр игр: названия и описания --- + "game.tonk.name": {"Тонк (Tonk)", "Tonk"}, + "game.tonk.desc": {"Карточная игра на 1 человека + 3 бота разного уровня сложности", "Card game for 1 human + 3 bots of varying skill"}, + "game.durak.name": {"Дурак (Durak)", "Durak (Russian Fool)"}, + "game.durak.desc": {"Классическая карточная игра на 2-6 игроков (подкидной вариант)", "Classic card game for 2-6 players (full throw-in variant)"}, + "game.bj.name": {"Блэкджек (Blackjack)", "Blackjack"}, + "game.bj.desc": {"Каждый играет против дилера, 1-6 игроков включая человека", "Everyone plays the dealer separately, 1-6 players including you"}, + "game.101.name": {"101", "101"}, + "game.101.desc": {"Избавьтесь от карт первым; штрафные очки за оставшиеся на руках, до 101 — выбывание", "Empty your hand first; penalty points for cards left in hand, reaching 101 eliminates you"}, + "game.1000.name": {"1000 (Тысяча)", "1000 (Thousand)"}, + "game.1000.desc": {"Взяточная игра с торгами и марьяжами, строго на троих (вы + 2 бота)", "Trick-taking game with bidding and marriages, strictly 3 players (you + 2 bots)"}, + "game.klondike.name": {"Косынка (Klondike)", "Klondike Solitaire"}, + "game.klondike.desc": {"Классический пасьянс на одного игрока, без ботов", "Classic single-player solitaire, no bots"}, + "game.checkers.name": {"Шашки (Checkers)", "Checkers"}, + "game.checkers.desc": {"Русские шашки против бота (минимакс), обязательное взятие", "Russian draughts against a minimax bot, mandatory captures"}, + + // --- Подменю "Правила" и сам экран правил --- + "rulesmenu.title": {"=== ПРАВИЛА: ВЫБЕРИТЕ ИГРУ ===", "=== RULES: CHOOSE A GAME ==="}, + "rulesmenu.hint": {"[↑↓/kj] выбор [enter] показать правила [esc/q] назад в меню", "[↑↓/kj] select [enter] show rules [esc/q] back to menu"}, + "rules.title": {"=== ПРАВИЛА ===", "=== RULES ==="}, + "rules.hint": {"[↑↓/kj] строка [pgup/pgdown] страница [g/G] начало/конец [esc/q] назад", "[↑↓/kj] line [pgup/pgdown] page [g/G] start/end [esc/q] back"}, + "rules.scroll_info": {"(строки %d-%d из %d)", "(lines %d-%d of %d)"}, + + // --- Настройка ставки (Тонк) --- + "stake.title": {"НАСТРОЙКА СТАВКИ", "STAKE SETUP"}, + "stake.capital": {"Стартовый капитал", "Starting capital"}, + "stake.ante": {"Ставка (анте) за раунд", "Ante per round"}, + "stake.tonk_mult": {"Множитель банка при Тонке", "Tonk pot multiplier"}, + "stake.pot_info": {"Банк на 4 игроков", "Pot for 4 players"}, + "stake.tonk_win": {"Победа Тонком", "Tonk win"}, + "stake.hint": {"[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад", "[↑↓/kj] field [←→/hl] change value [enter] start game [esc/q] back"}, + + // --- Выбор числа игроков (Дурак, 101) --- + "playercount.title": {"ЧИСЛО ИГРОКОВ", "NUMBER OF PLAYERS"}, + "playercount.line": {"игроков (включая вас)", "players (including you)"}, + "playercount.bots": {"Вы + %d бот(ов) со случайными именами.", "You + %d bot(s) with random names."}, + "playercount.hint": {"[←→/hl] изменить число игроков (2-6) [enter] начать игру [esc/q] назад", "[←→/hl] change player count (2-6) [enter] start game [esc/q] back"}, + + // --- Настройка Блэкджека --- + "bjsettings.title": {"НАСТРОЙКА", "SETUP"}, + "bjsettings.players": {"Число игроков (включая вас)", "Number of players (including you)"}, + "bjsettings.capital": {"Стартовый капитал", "Starting capital"}, + "bjsettings.bet": {"Ставка за раунд", "Bet per round"}, + "bjsettings.multi": {"Вы + %d бот(ов) со случайными именами, каждый играет против дилера отдельно.", "You + %d bot(s) with random names, each playing the dealer separately."}, + "bjsettings.solo": {"Вы играете в одиночку против дилера.", "You play solo against the dealer."}, + "bjsettings.hint": {"[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад", "[↑↓/kj] field [←→/hl] change value [enter] start game [esc/q] back"}, + + // --- Уровень сложности (Шашки) --- + "checkerslvl.title": {"УРОВЕНЬ СЛОЖНОСТИ", "DIFFICULTY LEVEL"}, + "checkerslvl.hint": {"[↑↓/←→] выбор уровня [enter] начать игру [esc/q] назад", "[↑↓/←→] choose level [enter] start game [esc/q] back"}, + "checkerslvl.easy": {"Простой", "Easy"}, + "checkerslvl.easy_desc": {"быстрый, ощутимо слабее", "fast, noticeably weaker"}, + "checkerslvl.hard": {"Сильный", "Strong"}, + "checkerslvl.hard_desc": {"заметно сильнее, чуть медленнее думает", "noticeably stronger, thinks a bit slower"}, + + // --- Сообщения об ошибках движков (ключ — сам русский текст, + // возвращаемый err.Error(); переводится в момент отображения в + // TUI через T(err.Error()), сами errors.New(...) не трогаем, + // чтобы не сломать сравнения ошибок по идентичности в тестах) --- + "в исходной стопке нет карт": {"в исходной стопке нет карт", "no cards in the source pile"}, + "вес руки превышает порог объявления Тонка": {"вес руки превышает порог объявления Тонка", "hand value exceeds the Tonk declaration threshold"}, + "доступно взятие — простой ход запрещён": {"доступно взятие — простой ход запрещён", "a capture is available — a simple move is not allowed"}, + "карту нельзя подложить к этой комбинации": {"карту нельзя подложить к этой комбинации", "this card can't be added to that combination"}, + "карты не образуют валидную комбинацию": {"карты не образуют валидную комбинацию", "these cards don't form a valid combination"}, + "карты нет на руке игрока": {"карты нет на руке игрока", "that card isn't in the player's hand"}, + "колода и сброс исчерпаны — раунд закончен вничью": {"колода и сброс исчерпаны — раунд закончен вничью", "stock and discard pile are exhausted — the round ends in a draw"}, + "комбинация на столе не найдена": {"комбинация на столе не найдена", "combination not found on the table"}, + "на столе нет карт": {"на столе нет карт", "there are no cards on the table"}, + "на столе нет карты, которую нужно отбивать": {"на столе нет карты, которую нужно отбивать", "there's no card on the table to beat"}, + "на фундамент можно положить только одну карту": {"на фундамент можно положить только одну карту", "only one card can be moved to a foundation"}, + "на этой клетке нет вашей шашки": {"на этой клетке нет вашей шашки", "you don't have a piece on that square"}, + "недопустимое действие для текущей фазы": {"недопустимое действие для текущей фазы", "this action isn't allowed in the current phase"}, + "недопустимое действие для текущей фазы хода": {"недопустимое действие для текущей фазы хода", "this action isn't allowed in the current turn phase"}, + "недостаточно средств для этого действия": {"недостаточно средств для этого действия", "not enough funds for this action"}, + "нечего отменять": {"нечего отменять", "nothing to undo"}, + "нужно продолжать бить той же шашкой": {"нужно продолжать бить той же шашкой", "you must continue capturing with the same piece"}, + "нужно сыграть карту, которая подходит — брать из колоды нельзя": {"нужно сыграть карту, которая подходит — брать из колоды нельзя", "you must play a matching card — drawing from the stock isn't allowed"}, + "нужно ходить в масть или козырем, если они есть на руке": {"нужно ходить в масть или козырем, если они есть на руке", "you must follow suit or play trump if you have one"}, + "партия уже завершена": {"партия уже завершена", "the match is already over"}, + "первый ходящий обязан назвать ставку не менее 100 и не может пасовать": {"первый ходящий обязан назвать ставку не менее 100 и не может пасовать", "the first bidder must bid at least 100 and cannot pass"}, + "сейчас не ваш ход": {"сейчас не ваш ход", "it isn't your turn right now"}, + "ставка должна быть выше текущей минимум на 5": {"ставка должна быть выше текущей минимум на 5", "the bid must be at least 5 higher than the current one"}, + "ставка превышает разрешённый максимум (120 плюс марьяж на руке)": {"ставка превышает разрешённый максимум (120 плюс марьяж на руке)", "the bid exceeds the allowed maximum (120 plus any marriage in hand)"}, + "стопка сброса пуста": {"стопка сброса пуста", "the discard pile is empty"}, + "такой ход недопустим": {"такой ход недопустим", "that move isn't allowed"}, + "удвоить ставку можно только на первых двух картах": {"удвоить ставку можно только на первых двух картах", "you can only double down on your first two cards"}, + "уже отдано нужное количество карт": {"уже отдано нужное количество карт", "the required number of cards has already been passed"}, + "эта карта (или последовательность под ней) не может быть перемещена": {"эта карта (или последовательность под ней) не может быть перемещена", "this card (or the sequence under it) can't be moved"}, + "эта карта не бьёт атакующую карту": {"эта карта не бьёт атакующую карту", "this card doesn't beat the attacking card"}, + "эта карта не подходит по масти или рангу": {"эта карта не подходит по масти или рангу", "this card doesn't match by suit or rank"}, + "эту карту нельзя подкинуть: не тот ранг или лимит атаки исчерпан": {"эту карту нельзя подкинуть: не тот ранг или лимит атаки исчерпан", "this card can't be thrown in: wrong rank or the attack limit is reached"}, + "эту карту нельзя положить в эту колонну": {"эту карту нельзя положить в эту колонну", "this card can't be placed in that column"}, + "эту карту нельзя положить на фундамент": {"эту карту нельзя положить на фундамент", "this card can't be placed on the foundation"}, + + // --- Общие ключи, используемые в нескольких играх --- + "common.you": {"Вы", "You"}, + "common.you_suffix": {" (вы)", " (you)"}, + "common.goodbye": {"До встречи!\n", "See you!\n"}, + "common.your_hand": {"Ваша рука:", "Your hand:"}, + "common.thinking": {"%s думает...\n", "%s is thinking...\n"}, + "common.no_cards": {"(нет карт)", "(no cards)"}, + + // --- Тонк --- + "tonk.title": {"=== ТОНК ===", "=== TONK ==="}, + "tonk.status_line": {"Колода: %d карт Сброс сверху: %s Банк раунда: %d (ставка: %d, Тонк ×%d)\n\n", "Deck: %d cards Top discard: %s Round pot: %d (ante: %d, Tonk ×%d)\n\n"}, + "tonk.melds_label": {"Комбинации на столе:", "Combinations on the table:"}, + "tonk.table_empty": {"(стол пуст)", "(table is empty)"}, + "tonk.set": {"Сет", "Set"}, + "tonk.sequence": {"Сиквенс", "Sequence"}, + "tonk.meld_line": {" [%d] %s (%s): %s\n", " [%d] %s (%s): %s\n"}, + "tonk.player_line": {"%s%-24s карт: %-2d капитал: %d", "%s%-24s cards: %-2d balance: %d"}, + "tonk.hand_weight": {"Вес руки: %d\n\n", "Hand weight: %d\n\n"}, + "tonk.hint.draw": {"Ваш ход. [d] взять из колоды [p] взять из сброса (%s) [k] рискнуть дропом [q] в меню", "Your turn. [d] draw from stock [p] draw discard (%s) [k] risk a drop [q] menu"}, + "tonk.hint.tonk_available": {" [t] Тонк доступен!", " [t] Tonk available!"}, + "tonk.hint.action1": {"[←→/hl] выбор карты [space] отметить для комбинации [m] выложить комбинацию", "[←→/hl] choose card [space] mark for combination [m] lay combination"}, + "tonk.hint.action2": {"[1-9] подложить текущую карту к комбинации №N [enter] сбросить текущую карту", "[1-9] add current card to combination #N [enter] discard current card"}, + "tonk.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"}, + "tonk.msg.bot_moved": {"%s сходил(а).", "%s made a move."}, + "tonk.msg.drew_deck": {"Вы взяли карту из колоды.", "You drew a card from the stock."}, + "tonk.msg.drew_discard": {"Вы взяли из сброса: %s", "You took from the discard pile: %s"}, + "tonk.msg.drop_caught": {"Дроп не удался — вас поймали! Банк и штраф достались %s.", "The drop failed — you got caught! The pot and penalty went to %s."}, + "tonk.msg.drop_success": {"Дроп удался! Вы забрали банк.", "The drop succeeded! You took the pot."}, + "tonk.msg.tonk_declared": {"Вы объявили Тонк!", "You declared Tonk!"}, + "tonk.msg.discarded": {"Вы сбросили %s.", "You discarded %s."}, + "tonk.msg.meld_laid": {"Комбинация выложена на стол.", "Combination laid on the table."}, + "tonk.msg.card_added": {"Карта %s подложена к комбинации №%d.", "Card %s added to combination #%d."}, + "выберите (пробел) минимум 3 карты для комбинации": {"выберите (пробел) минимум 3 карты для комбинации", "select (space) at least 3 cards for a combination"}, + "на столе нет комбинации №%d": {"на столе нет комбинации №%d", "there's no combination #%d on the table"}, + "бот %s: %w": {"бот %s: %w", "bot %s: %w"}, + "tonk.bankrupt.human": {"Вы обанкротились и выбыли! Ваше место занял новый игрок: %s. Дальше играют боты — можно наблюдать или [q] выйти в меню.", + "You went bankrupt and are out! Your seat was taken by a new player: %s. The bots will keep playing — you can watch or press [q] to go to the menu."}, + "tonk.bankrupt.bot": {"%s обанкротился(лась) и выбыл(а). За стол сел новый игрок: %s (капитал %d).", + "%s went bankrupt and is out. A new player sat down: %s (balance %d)."}, + "tonk.result.dead": {"Раунд закончился вничью: колода и сброс исчерпаны. Банк (%d) возвращён игрокам.", "The round ended in a draw: stock and discard pile are exhausted. The pot (%d) was returned to the players."}, + "tonk.result.tonk": {"%s объявил(а) Тонк и забрал(а) банк ×%d (%d)!", "%s declared Tonk and took the pot ×%d (%d)!"}, + "tonk.result.drop_caught": {"Дроп не удался! %s оказался(лась) реально ниже всех и забрал(а) банк с штрафом!", "The drop failed! %s really had the lowest hand and took the pot plus the penalty!"}, + "tonk.result.drop_success": {"%s рискнул(а) дропом и выиграл(а) — забрал(а) банк (%d)!", "%s risked a drop and won — took the pot (%d)!"}, + "tonk.result.win": {"%s избавился(лась) от всех карт и забрал(а) банк (%d)!", "%s emptied their hand and took the pot (%d)!"}, + "tonk.result.delta_line": {" %-24s изменение капитала: %+d (итого: %d)\n", " %-24s balance change: %+d (total: %d)\n"}, + + // --- Дурак --- + "durak.title": {"=== ДУРАК ===", "=== DURAK ==="}, + "durak.legend": {"(A — атакующий, D — защищающийся, T — сейчас подкидывает)", "(A — attacker, D — defender, T — currently throwing in)"}, + "durak.status_line": {"Козырь: %s В колоде: %d карт\n\n", "Trump: %s Cards left: %d\n\n"}, + "durak.table_label": {"Стол (атака/защита):", "Table (attack/defense):"}, + "durak.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "durak.hint.attack": {"[←→/hl] выбор карты [enter] подкинуть карту (обязательный первый ход) [q] в меню", "[←→/hl] choose card [enter] play card (mandatory first move) [q] menu"}, + "durak.hint.defend": {"[←→/hl] выбор карты [enter] отбить карту [t] забрать стол [q] в меню", "[←→/hl] choose card [enter] beat the card [t] take the table [q] menu"}, + "durak.hint.throwin": {"[←→/hl] выбор карты [enter] подкинуть ещё карту [p] пасовать [q] в меню", "[←→/hl] choose card [enter] throw in another card [p] pass [q] menu"}, + "durak.msg.threw_in": {"Вы подкинули %s.", "You threw in %s."}, + "durak.msg.passed": {"Вы пасовали.", "You passed."}, + "durak.msg.defended": {"Вы отбились картой %s.", "You beat it with %s."}, + "durak.msg.took": {"Вы забрали стол себе в руку.", "You took the table into your hand."}, + "durak.status_out": {" [отбился]", " [safe]"}, + "durak.player_line": {"%s%-20s карт: %-2d%s", "%s%-20s cards: %-2d%s"}, + "durak.result.stalemate": {"Партия зашла в тупик (карты слишком долго не выходили из игры) и объявлена ничьей.", "The game reached a stalemate (cards weren't leaving play for too long) and was declared a draw."}, + "durak.result.noloser": {"Ничья! Все избавились от карт одновременно — дурака нет.", "A draw! Everyone emptied their hands at the same time — no fool this time."}, + "durak.result.loser": {"%s остался(лась) с картами на руках — дурак!", "%s was left holding cards — the fool!"}, +} + +// blackjackTranslations добавляется отдельным init(), чтобы не +// раздувать один литерал карты translations — Go не позволяет +// повторно объявить один и тот же map-литерал в двух местах, а +// добавлять записи в уже созданную карту через init можно. +func init() { + for k, v := range map[string]translationEntry{ + "blackjack.title": {"=== БЛЭКДЖЕК ===", "=== BLACKJACK ==="}, + "blackjack.dealer_line": {"Дилер: %s\n\n", "Dealer: %s\n\n"}, + "blackjack.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"}, + "blackjack.hint.turn": {"[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню", "[h] hit [s] stand [d] double down [q] menu"}, + "blackjack.msg.hit": {"Вы взяли карту.", "You took a card."}, + "blackjack.msg.stand": {"Вы остановились.", "You stood."}, + "blackjack.msg.double": {"Вы удвоили ставку.", "You doubled down."}, + "blackjack.status.stood": {"стоп", "stood"}, + "blackjack.status.busted": {"перебор", "bust"}, + "blackjack.status.blackjack": {"блэкджек!", "blackjack!"}, + "blackjack.status.playing": {"ходит", "playing"}, + "blackjack.player_line": {"%s%-20s ставка: %-4d карты: %s (%d) [%s]", "%s%-20s bet: %-4d cards: %s (%d) [%s]"}, + "blackjack.dealer_result": {"Дилер: %s (%d)", "Dealer: %s (%d)"}, + "blackjack.busted_suffix": {" — перебор!", " — bust!"}, + "blackjack.outcome.blackjack": {"блэкджек! выигрыш 3:2", "blackjack! 3:2 win"}, + "blackjack.outcome.win": {"выигрыш", "win"}, + "blackjack.outcome.push": {"ничья, ставка возвращена", "push, bet returned"}, + "blackjack.outcome.lose": {"проигрыш", "loss"}, + "blackjack.outcome.bust": {"перебор, проигрыш", "bust, loss"}, + "blackjack.result_line": {" %-20s %-24s изменение капитала: %+d (итого: %d)", " %-20s %-24s balance change: %+d (total: %d)"}, + + "oneohone.title": {"=== 101 ===", "=== 101 ==="}, + "oneohone.status_line": {"Сброс сверху: %s В колоде: %d карт\n\n", "Top discard: %s Cards left: %d\n\n"}, + "oneohone.hint.gameover_match": {"[q] в меню", "[q] menu"}, + "oneohone.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"}, + "oneohone.hint.turn": {"[←→/hl] выбор карты [enter] сыграть карту [d] взять из колоды (если нечем ходить) [q] в меню", "[←→/hl] choose card [enter] play card [d] draw from stock (if you can't play) [q] menu"}, + "oneohone.msg.played": {"Вы сыграли %s.", "You played %s."}, + "oneohone.msg.drew": {"Вы взяли карту.", "You drew a card."}, + "oneohone.status.out": {" [выбыл]", " [out]"}, + "oneohone.player_line": {"%s%-16s карт: %-2d счёт: %-4d%s", "%s%-16s cards: %-2d score: %-4d%s"}, + "oneohone.result.stalemate": {"Раунд зашёл в тупик и принудительно завершён без изменения счёта.", "The round hit a stalemate and was forced to end with no score change."}, + "oneohone.result.win": {"%s избавился(лась) от всех карт и выиграл(а) раунд!", "%s emptied their hand and won the round!"}, + "oneohone.result.queen_bonus": {" (бонус за даму!)", " (queen bonus!)"}, + "oneohone.result.delta_line": {" %-16s %+d очков за раунд (итого: %d)%s", " %-16s %+d points this round (total: %d)%s"}, + "oneohone.result.eliminated": {" — выбыл(а) из партии!", " — eliminated from the match!"}, + "oneohone.result.reset": {" — ровно 101, счёт обнулён!", " — exactly 101, score reset!"}, + "oneohone.result.no_winner": {"Партия завершилась без победителя (все выбыли одновременно).", "The match ended with no winner (everyone was eliminated at the same time)."}, + "oneohone.result.match_over": {"Партия окончена! Победитель: %s", "Match over! Winner: %s"}, + + "thousand.title": {"=== 1000 ===", "=== 1000 ==="}, + "thousand.hint.gameover_match": {"[q] в меню", "[q] menu"}, + "thousand.hint.gameover": {"[n] новая раздача [q] в меню", "[n] new hand [q] menu"}, + "thousand.bidding_status": {"Текущая максимальная ставка: %d (у %s)\n\n", "Current high bid: %d (by %s)\n\n"}, + "thousand.trump_unset": {"ещё не назначен", "not set yet"}, + "thousand.play_status": {"Ставка: %d (у %s) Козырь: %s\n", "Bid: %d (by %s) Trump: %s\n"}, + "thousand.trick_label": {"Взятка:", "Trick:"}, + "thousand.your_hand": {"Ваша рука:", "Your hand:"}, + "thousand.hint.bid": {"[↑→/kl] +5 [↓←/hj] -5 [enter] поставить %d [p] пас\n", "[↑→/kl] +5 [↓←/hj] -5 [enter] bid %d [p] pass\n"}, + "thousand.hint.discard": {"[←→/hl] выбор карты [enter] отдать карту следующему сопернику", "[←→/hl] choose card [enter] give the card to the next opponent"}, + "thousand.hint.trick": {"[←→/hl] выбор карты [enter] сыграть карту (король/дама масти = объявить марьяж)", "[←→/hl] choose card [enter] play card (King/Queen of a suit = declare a marriage)"}, + "thousand.hint.menu": {"[q] в меню", "[q] menu"}, + "thousand.msg.bid": {"Вы поставили %d.", "You bid %d."}, + "thousand.msg.passed": {"Вы пасовали.", "You passed."}, + "thousand.msg.gave_card": {"Вы отдали %s.", "You gave away %s."}, + "thousand.msg.played": {"Вы сыграли %s.", "You played %s."}, + "thousand.no_bidder": {"никого — торги ещё не начались", "no one — bidding hasn't started yet"}, + "thousand.suit.clubs": {"трефы", "clubs"}, + "thousand.suit.diamonds": {"бубны", "diamonds"}, + "thousand.suit.hearts": {"червы", "hearts"}, + "thousand.suit.spades": {"пики", "spades"}, + "thousand.player_line": {"%s%-14s карт: %-2d очки за раздачу: %-4d счёт: %-5d%s", "%s%-14s cards: %-2d hand points: %-4d score: %-5d%s"}, + "thousand.declarer_tag": {" [торг]", " [bid]"}, + "thousand.trick_not_started": {"(взятка ещё не начата)", "(trick not started yet)"}, + "thousand.result.made": {"%s выполнил(а) ставку %d!", "%s made the bid of %d!"}, + "thousand.result.failed": {"%s не выполнил(а) ставку %d.", "%s failed to make the bid of %d."}, + "thousand.result.delta_line": {" %-14s %+d очков за раздачу (итого: %d)", " %-14s %+d points this hand (total: %d)"}, + "thousand.result.match_over": {"Партия окончена! Победитель: %s", "Match over! Winner: %s"}, + + "klondike.title": {"=== КОСЫНКА ===", "=== KLONDIKE ==="}, + "klondike.msg.undone": {"Ход отменён.", "Move undone."}, + "klondike.msg.drew": {"Взяли карту из колоды.", "Drew a card from the stock."}, + "klondike.msg.moved": {"Ход выполнен.", "Move made."}, + "klondike.msg.to_foundation": {"Карта отправлена на фундамент.", "Card sent to the foundation."}, + "klondike.empty": {"(пусто)", "(empty)"}, + "klondike.status_line": {"Колода: %s Отбой: %s", "Stock: %s Waste: %s"}, + "klondike.foundations_line": {"Фундаменты: %s\n", "Foundations: %s\n"}, + "klondike.moves_line": {"Ходов: %d\n\n", "Moves: %d\n\n"}, + "klondike.won": {"Пасьянс сошёлся за %d ходов! Поздравляем!", "Solved in %d moves! Congratulations!"}, + "klondike.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "klondike.hint.noselect": {"[1-7] выбрать колонну [0] выбрать отбой [d] взять из колоды", "[1-7] select column [0] select waste [d] draw from stock"}, + "klondike.hint.selected": {"[1-7] переложить в колонну [f] на фундамент [esc] отменить выбор [d] взять из колоды", "[1-7] move to column [f] to foundation [esc] cancel selection [d] draw from stock"}, + "klondike.hint.footer": {"[u] отменить ход [n] новая партия [q] в меню", "[u] undo move [n] new game [q] menu"}, + + "checkers.title": {"=== ШАШКИ ===", "=== CHECKERS ==="}, + "checkers.legend": {"(o — простая шашка, O — дамка; светлые буквы — ваши белые, тёмные — чёрные бота)", "(o — man, O — king; light letters are your white pieces, dark are the bot's black pieces)"}, + "chess.legend": {"(K/Q/R/B/N/P — король/ферзь/ладья/слон/конь/пешка; заглавные — ваши белые, строчные — чёрные бота)", "(K/Q/R/B/N/P — king/queen/rook/bishop/knight/pawn; uppercase are your white pieces, lowercase are the bot's black pieces)"}, + "checkers.msg.bot_moved": {"Бот сходил.", "The bot made a move."}, + "checkers.msg.moved": {"Ход выполнен.", "Move made."}, + "checkers.result.draw": {"Ничья (слишком долго не было взятий).", "Draw (no captures for too long)."}, + "checkers.result.win": {"Вы выиграли!", "You won!"}, + "checkers.result.lose": {"Победил бот.", "The bot won."}, + "checkers.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "checkers.turn.you": {"Ваш ход (белые)", "Your turn (white)"}, + "checkers.turn.bot": {"Бот думает (чёрные)...", "The bot is thinking (black)..."}, + "checkers.hint.noselect": {"[↑↓←→/hjkl] курсор [enter] выбрать шашку [q] в меню", "[↑↓←→/hjkl] cursor [enter] select a piece [q] menu"}, + "checkers.hint.selected": {"[↑↓←→/hjkl] курсор [enter] переместить (или отменить выбор) [esc] отменить выбор", "[↑↓←→/hjkl] cursor [enter] move (or cancel selection) [esc] cancel selection"}, + + "на этой клетке нет вашей фигуры": {"на этой клетке нет вашей фигуры", "you don't have a piece on that square"}, + + "chess.title": {"=== ШАХМАТЫ ===", "=== CHESS ==="}, + "chess.msg.bot_moved": {"Бот сходил.", "The bot made a move."}, + "chess.msg.moved": {"Ход выполнен.", "Move made."}, + "chess.check": {"Шах!", "Check!"}, + "chess.result.win_checkmate": {"Мат! Вы выиграли!", "Checkmate! You won!"}, + "chess.result.lose_checkmate": {"Мат! Победил бот.", "Checkmate! The bot won."}, + "chess.result.draw": {"Ничья (%s).", "Draw (%s)."}, + "chess.drawreason.stalemate": {"пат", "stalemate"}, + "chess.drawreason.insufficient_material": {"недостаточно материала для мата", "insufficient material"}, + "chess.drawreason.no_progress": {"слишком долго без взятий и ходов пешками", "too long without captures or pawn moves"}, + "chess.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "chess.turn.you": {"Ваш ход (белые)", "Your turn (white)"}, + "chess.turn.bot": {"Бот думает (чёрные)...", "The bot is thinking (black)..."}, + "chess.hint.noselect": {"[↑↓←→/hjkl] курсор [enter] выбрать фигуру [q] в меню", "[↑↓←→/hjkl] cursor [enter] select a piece [q] menu"}, + "chess.hint.selected": {"[↑↓←→/hjkl] курсор [enter] переместить (или отменить выбор) [esc] отменить выбор", "[↑↓←→/hjkl] cursor [enter] move (or cancel selection) [esc] cancel selection"}, + + "game.chess.name": {"Шахматы (Chess)", "Chess"}, + "game.chess.desc": {"Классические шахматы против бота (минимакс), с рокировкой и взятием на проходе", "Classic chess against a minimax bot, with castling and en passant"}, + + "game.corpsestarch.name": {"Трупные батончики (Corpse-Starch Box)", "Corpse-Starch Box"}, + "game.corpsestarch.desc": {"Инкрементальная игра: копите трупный крахмал, открывайте новые механики по ходу партии", "An incremental game: gather corpse starch, unlock new mechanics as you go"}, + + "corpsestarch.title": {"=== ТРУПНЫЕ БАТОНЧИКИ ===", "=== CORPSE-STARCH BOX ==="}, + "corpsestarch.header.starch": {"Трупный крахмал: %s (всего добыто: %s)", "Corpse starch: %s (total gathered: %s)"}, + "corpsestarch.header.corruption": {"Скверна: %d", "Corruption: %d"}, + "corpsestarch.hint": {"[1-9] выбрать действие [q] в меню (партия автосохраняется)", "[1-9] choose an action [q] menu (game is autosaved)"}, + + "corpsestarch.action.requisition": {"Реквизировать трупный крахмал (+%s)", "Requisition corpse starch (+%s)"}, + "corpsestarch.action.buy_servitor": {"Приобрести: %s (сейчас: %d, цена: %s)", "Acquire: %s (owned: %d, cost: %s)"}, + "corpsestarch.action.upgrade_click": {"Заточить цепной меч (цена: %s)", "Sharpen chainsword (cost: %s)"}, + "corpsestarch.action.dip": {"Окунуть цепной меч в отстойник", "Dip your chainsword in the sump"}, + "corpsestarch.action.start_mission": {"Начать зачистку: %s (%s)", "Start purge: %s (%s)"}, + "corpsestarch.action.collect_mission": {"Забрать награду за зачистку", "Collect purge reward"}, + + "corpsestarch.msg.requisitioned": {"Реквизиция одобрена Муниторумом.", "Requisition approved by the Munitorum."}, + "corpsestarch.msg.servitor_bought": {"Новая единица введена в строй.", "A new unit has been brought online."}, + "corpsestarch.msg.upgrade_bought": {"Цепной меч заточен острее.", "The chainsword is sharper now."}, + "corpsestarch.msg.cannot_afford": {"Недостаточно трупного крахмала.", "Not enough corpse starch."}, + "corpsestarch.msg.mission_started": {"Отряд отправлен на зачистку.", "A squad has been dispatched on the purge."}, + + "corpsestarch.mission.inprogress": {"Зачистка идёт: %s (осталось %s)", "Purge underway: %s (%s remaining)"}, + "corpsestarch.mission.ready": {"Зачистка завершена: %s — заберите награду!", "Purge complete: %s — collect your reward!"}, + + "corpsestarch.log.title": {"Журнал Муниторума:", "Munitorum log:"}, + "corpsestarch.log.dip_bonus": {"Отстойник выплюнул горсть трупного крахмала.", "The sump spat out a handful of corpse starch."}, + "corpsestarch.log.dip_blessing": {"Дух машины благословил ваш клинок — временный прирост дохода.", "The machine spirit blessed your blade — temporary income boost."}, + "corpsestarch.log.dip_nothing": {"Отстойник ответил тишиной.", "The sump answers only with silence."}, + "corpsestarch.log.dip_corruption": {"Скверна коснулась вашего снаряжения.", "Taint has touched your wargear."}, + "corpsestarch.log.mission_done": {"Отряд вернулся с зачистки с трофеями.", "The squad returned from the purge with spoils."}, + + "corpsestarch.servitor.skull": {"Сервочерп", "Servo-Skull"}, + "corpsestarch.servitor.menial": {"Сервитор-подёнщик", "Menial Servitor"}, + "corpsestarch.servitor.cogitator": {"Когитаторный комплекс", "Cogitator Array"}, + + "corpsestarch.mission.vermin": {"Зачистить улей от паразитов", "Purge the Hive Vermin"}, + "corpsestarch.mission.chapel": {"Очистить часовню от еретиков", "Cleanse the Chapel of Heretics"}, + "corpsestarch.mission.waaagh": {"Заглушить барабаны Вааагх!", "Silence the Ork Waaagh Drums"}, + + "corpsestarch.msg.welcome_back": {"С возвращением! Пока вас не было, сервиторы намолотили +%s трупного крахмала.", "Welcome back! While you were away, your servitors gathered +%s corpse starch."}, + + "game.go.name": {"Го (Go)", "Go"}, + "game.go.desc": {"Классическая настольная игра на доске 9x9/13x13/19x19, три уровня сложности бота", "Classic board game on a 9x9/13x13/19x19 board, three bot difficulty levels"}, + + "gosettings.title": {"НАСТРОЙКА ПАРТИИ", "GAME SETUP"}, + "gosettings.boardsize": {"Размер доски", "Board size"}, + "gosettings.difficulty": {"Сложность бота", "Bot difficulty"}, + "gosettings.hint": {"[↑↓/kj] поле [←→/hl] изменить значение [enter] начать игру [esc/q] назад", "[↑↓/kj] field [←→/hl] change value [enter] start game [esc/q] back"}, + "gosettings.level.easy": {"Простой", "Easy"}, + "gosettings.level.medium": {"Средний", "Medium"}, + "gosettings.level.hard": {"Сильный", "Strong"}, + + "go.title": {"=== ГО ===", "=== GO ==="}, + "go.header.captures": {"Взято чёрными: %d Взято белыми: %d", "Captured by black: %d Captured by white: %d"}, + "go.color.black": {"Чёрные", "Black"}, + "go.color.white": {"Белые", "White"}, + "go.turn.you": {"Ваш ход (чёрные)", "Your turn (black)"}, + "go.turn.bot": {"Бот думает (белые)...", "The bot is thinking (white)..."}, + "go.hint.play": {"[↑↓←→/hjkl] курсор [enter] поставить камень [p] пасовать [q] в меню", "[↑↓←→/hjkl] cursor [enter] place a stone [p] pass [q] menu"}, + "go.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "go.msg.bot_moved": {"Бот сходил.", "The bot made a move."}, + "go.msg.played": {"Камень поставлен.", "Stone placed."}, + "go.msg.passed": {"Вы пасовали.", "You passed."}, + "go.result.line": {"Партия окончена! Победили %s (счёт: %.1f — %.1f)", "Game over! %s win (score: %.1f — %.1f)"}, + + "точка вне пределов доски": {"точка вне пределов доски", "that point is off the board"}, + "точка уже занята": {"точка уже занята", "that point is already occupied"}, + "этот ход самоубийственный — так ходить нельзя": {"этот ход самоубийственный — так ходить нельзя", "that move is suicidal — it isn't allowed"}, + "этот ход запрещён правилом ко": {"этот ход запрещён правилом ко", "that move is forbidden by the ko rule"}, + + "game.minesweeper.name": {"Сапёр (Minesweeper)", "Minesweeper"}, + "game.minesweeper.desc": {"Классический Сапёр, три пресета размера поля на выбор", "Classic Minesweeper, three field size presets to choose from"}, + + "mssettings.title": {"НАСТРОЙКА ПОЛЯ", "FIELD SETUP"}, + "mssettings.hint": {"[↑↓/kj] выбор пресета [enter] начать игру [esc/q] назад", "[↑↓/kj] choose preset [enter] start game [esc/q] back"}, + "mssettings.beginner": {"Новичок", "Beginner"}, + "mssettings.intermediate": {"Любитель", "Intermediate"}, + "mssettings.expert": {"Эксперт", "Expert"}, + + "minesweeper.title": {"=== САПЁР ===", "=== MINESWEEPER ==="}, + "minesweeper.header.mines": {"Осталось мин: %d", "Mines remaining: %d"}, + "minesweeper.hint.play": {"[↑↓←→/hjkl] курсор [enter] открыть/хорда [f] флаг [q] в меню", "[↑↓←→/hjkl] cursor [enter] reveal/chord [f] flag [q] menu"}, + "minesweeper.hint.gameover": {"[n] новая партия [q] в меню", "[n] new game [q] menu"}, + "minesweeper.msg.revealed": {"Клетка открыта.", "Cell revealed."}, + "minesweeper.msg.boom": {"Бах! Это была мина.", "Boom! That was a mine."}, + "minesweeper.msg.flagged": {"Флаг установлен.", "Flag placed."}, + "minesweeper.msg.unflagged": {"Флаг снят.", "Flag removed."}, + "minesweeper.msg.chorded": {"Соседние клетки открыты.", "Neighboring cells revealed."}, + + "эта клетка ещё не открыта": {"эта клетка ещё не открыта", "that cell isn't revealed yet"}, + "minesweeper.result.win": {"Поздравляем! Все безопасные клетки открыты.", "Congratulations! All safe cells revealed."}, + "minesweeper.result.lose": {"Вы подорвались на мине. Партия окончена.", "You hit a mine. Game over."}, + + "клетка вне пределов поля": {"клетка вне пределов поля", "that cell is off the field"}, + "клетка уже открыта": {"клетка уже открыта", "that cell is already revealed"}, + "сначала снимите флаг с этой клетки": {"сначала снимите флаг с этой клетки", "remove the flag from that cell first"}, + } { + translations[k] = v + } +} diff --git a/translations_wayfarer1.go b/translations_wayfarer1.go new file mode 100644 index 0000000..356429d --- /dev/null +++ b/translations_wayfarer1.go @@ -0,0 +1,95 @@ +package main + +// translationsWayfarerQuests1 добавляется через init() — тексты +// первых двух квестов (сверхновая, эскорт). +func init() { + for k, v := range map[string]translationEntry{ + // --- 1. Сверхновая --- + "quest.supernova.title": {"Эвакуация Лаис II", "Evacuation of Lais II"}, + "quest.supernova.brief": {"Спасти колонистов до вспышки сверхновой", "Rescue colonists before the supernova hits"}, + "quest.supernova.step0": { + "Приняли сигнал бедствия с планеты Лаис II. Местное солнце вошло в фазу нестабильности — по расчётам станции, до вспышки часы, может быть меньше. На орбите ждут три транспорта с колонистами. Топлива у вас хватит на один-два рейса, не больше.", + "You've picked up a distress signal from Lais II. The local sun has gone unstable — station estimates put the flare at hours away, maybe less. Three transports full of colonists are waiting in orbit. You have fuel for one, maybe two runs, no more.", + }, + "quest.supernova.choice0.0": {"Взять на борт только учёных станции — гарантированно и быстро", "Take only the station's scientists aboard — quick and certain"}, + "quest.supernova.choice0.1": {"Попытаться вывезти всех колонистов", "Try to evacuate every colonist"}, + "quest.supernova.choice0.2": {"Слишком рискованно — улететь", "Too risky — leave"}, + "quest.supernova.result0.2": { + "Вы прыгаете прочь за миг до вспышки. Три транспорта с колонистами остаются на орбите. Позже в новостных лентах промелькнёт короткая сводка о погибшей колонии — без подробностей.", + "You jump away moments before the flare. Three transports full of colonists are left in orbit. Later, a brief note about a lost colony flickers through the news feeds — no further details.", + }, + "quest.supernova.step1": { + "Учёные благодарны и спешат на борт с самым ценным оборудованием. Вы видите, как за иллюминатором остальные жители молча смотрят на ваш корабль. Никто не просит взять их — все понимают правила приоритета.", + "The scientists are grateful and hurry aboard with their most valuable equipment. Through the viewport you see the rest of the colonists watching your ship in silence. No one asks to come along — everyone understands the priority rules.", + }, + "quest.supernova.choice1.0": {"Взлететь немедленно", "Launch immediately"}, + "quest.supernova.result1.0": { + "Вспышка настигает планету через считаные минуты после прыжка. Учёные благодарны за спасение и щедро платят за уцелевшие данные исследований. Груз, который они не успели забрать, теперь принадлежит только звёздам.", + "The flare hits the planet mere minutes after your jump. The scientists are grateful to be alive and pay generously for the surviving research data. Whatever they couldn't bring with them now belongs only to the stars.", + }, + "quest.supernova.step2": { + "Колонисты штурмуют шлюз с вещами в руках. Вместимость трюма официально рассчитана на груз, не на людей — но если утрамбовать всех в грузовой отсек без кресел и страховки, теоретически можно взять больше народу за один заход, серьёзно рискуя кораблём при манёврах.", + "Colonists are storming the airlock, belongings in hand. The cargo hold's rated capacity is meant for freight, not people — but if you cram everyone into the bay without seats or restraints, you could theoretically take more people in one run, at serious risk to the ship during maneuvers.", + }, + "quest.supernova.choice2.0": {"Набить трюм людьми под завязку — и будь что будет", "Cram the hold full of people — and hope for the best"}, + "quest.supernova.choice2.1": {"Взять только тех, кому хватит нормальных мест", "Only take as many as have proper seating"}, + "quest.supernova.step3": { + "Корпус скрипит от перегрузки при разгоне, автоматика ругается на превышение массы, но вы дожимаете прыжок в последний момент — планета вспыхивает позади разворачивающейся вспышкой света. В трюме — сотни спасённых жизней, и ни одной потерянной.", + "The hull groans under the overload during acceleration, the automation screams about exceeding mass limits, but you force the jump through at the last possible moment — the planet flares into light behind you. In the hold: hundreds of lives saved, and not one lost.", + }, + "quest.supernova.choice3.0": {"Долетели — почти на честном слове", "You made it — barely holding together"}, + "quest.supernova.result3.0": { + "Корабль на пределе, но все живы. Спасённые скидываются, кто чем может, и по прибытии в порт назначения набирается солидная сумма благодарности. Ваше имя теперь будут помнить на Лаис.", + "The ship is battered, but everyone is alive. The survivors pool together whatever they can, and by the time you reach port a solid reward has been gathered. Your name will be remembered on Lais.", + }, + "quest.supernova.step4": { + "Вы решаете не рисковать перегрузкой и берёте ровно столько, сколько трюм может нести безопасно. Оставшиеся колонисты — в основном пожилые и те, кто настоял пропустить детей вперёд — остаются ждать чуда, которого не будет.", + "You decide not to risk an overload and take exactly as many as the hold can safely carry. The colonists left behind — mostly the elderly, and those who insisted the children go first — are left waiting for a miracle that won't come.", + }, + "quest.supernova.choice4.0": {"Улететь с теми, кого взяли", "Leave with those you took"}, + "quest.supernova.result4.0": { + "Спасённые благодарны, но радость короткая — все знают, кто остался. Награда скромная: горе не располагает к щедрости, даже у выживших.", + "The survivors are grateful, but the relief is short-lived — everyone knows who was left behind. The reward is modest: grief doesn't make anyone generous, not even those who lived.", + }, + + // --- 2. Дипломатический эскорт --- + "quest.escort.title": {"Дипломатический эскорт", "Diplomatic Escort"}, + "quest.escort.brief": {"Довезти дипломата целым через опасный сектор", "Get a diplomat safely through a dangerous sector"}, + "quest.escort.step0": { + "На станции к вам подходит человек в строгом костюме: посольство ищет пилота, готового провезти дипломата через сектор, кишащий наёмниками соперничающей фракции. Плата хорошая, но и маршрут известный — опасный.", + "A man in a formal suit approaches you at the station: the embassy needs a pilot willing to carry a diplomat through a sector crawling with a rival faction's mercenaries. The pay is good, but the route is a known danger zone.", + }, + "quest.escort.choice0.0": {"Согласиться", "Accept"}, + "quest.escort.choice0.1": {"Отказаться — не стоит связываться с политикой", "Decline — not worth getting tangled in politics"}, + "quest.escort.result0.1": { + "Вы вежливо отказываетесь. Дипломат находит другого пилота — рискованнее ли для него так, вы никогда не узнаете.", + "You politely decline. The diplomat finds another pilot — whether that was riskier for them, you'll never know.", + }, + "quest.escort.step1": { + "На полпути через сектор радар вспыхивает тревогой: наёмники соперничающей фракции легли на перехват, явно поджидали именно ваш корабль. Дипломат в грузовом отсеке напряжённо молчит.", + "Halfway through the sector, the radar flares a warning: mercenaries from the rival faction have moved to intercept, clearly waiting for your ship specifically. The diplomat sits tense and silent in the cargo bay.", + }, + "quest.escort.choice1.0": {"Принять бой", "Stand and fight"}, + "quest.escort.choice1.1": {"Попытаться уйти манёвром на форсаже", "Try to outrun them on full burn"}, + "quest.escort.step2": { + "Бой короткий, но грязный — обшивка пробита в паре мест, однако наёмники отступают первыми. Дипломат выходит из укрытия бледный, но невредимый, и смотрит на вас с явным уважением.", + "The fight is short but ugly — the hull takes a couple of hits, but the mercenaries break off first. The diplomat emerges from cover pale but unhurt, and looks at you with unmistakable respect.", + }, + "quest.escort.choice2.0": {"Довезти дипломата до места назначения", "Deliver the diplomat to their destination"}, + "quest.escort.result2.0": { + "Дипломат прибывает вовремя и лично благодарит вас перед посольством — это открывает вам доступ к более выгодным контрактам в будущем. Оплата — щедрее, чем обещали изначально.", + "The diplomat arrives on time and personally thanks you in front of the embassy — this opens the door to better contracts down the line. The payment is more generous than originally promised.", + }, + "quest.escort.step3": { + "Двигатели ревут на пределе, но манёвр срабатывает — наёмники отстают на выходе из сектора. Дипломат явно на нервах и позже упоминает в отчёте, что пилот предпочёл не рисковать вступать в бой.", + "The engines scream at their limit, but the maneuver works — the mercenaries fall behind as you clear the sector. The diplomat is visibly rattled and later notes in their report that the pilot chose not to risk combat.", + }, + "quest.escort.choice3.0": {"Довезти дипломата целым и невредимым", "Deliver the diplomat safe and sound"}, + "quest.escort.result3.0": { + "Дипломат жив и цел — формально задание выполнено. Оплата стандартная, но без личной благодарности: осторожность оценили меньше, чем героизм.", + "The diplomat is alive and unharmed — the job is technically done. The payment is standard, but without personal gratitude: caution was valued less than heroics would have been.", + }, + } { + translations[k] = v + } +} diff --git a/translations_wayfarer2.go b/translations_wayfarer2.go new file mode 100644 index 0000000..5050be0 --- /dev/null +++ b/translations_wayfarer2.go @@ -0,0 +1,88 @@ +package main + +func init() { + for k, v := range map[string]translationEntry{ + // --- 3. Сигнал с обломков --- + "quest.derelict.title": {"Сигнал с обломков", "Signal From the Wreck"}, + "quest.derelict.brief": {"Исследовать загадочный покинутый корабль", "Investigate a mysterious derelict ship"}, + "quest.derelict.step0": { + "Сканеры фиксируют слабый, но устойчивый сигнал с дрейфующего корабля неизвестной модели. Судя по повреждениям, он в этом состоянии уже давно, но реактор всё ещё активен. Можно состыковаться и осмотреться, а можно просто пройти мимо.", + "Scanners pick up a faint but steady signal from a drifting ship of unfamiliar make. Judging by the damage, it's been like this for a long time, but the reactor is still active. You could dock and take a look, or simply move on.", + }, + "quest.derelict.choice0.0": {"Состыковаться и осмотреть корабль", "Dock and search the ship"}, + "quest.derelict.choice0.1": {"Не рисковать, пройти мимо", "Not worth the risk — move on"}, + "quest.derelict.result0.1": { + "Вы проходите мимо. Кем бы ни был экипаж и что бы ни случилось на этом корабле, его тайна останется нераскрытой — как минимум, не вами.", + "You move on. Whoever the crew was and whatever happened aboard that ship, its mystery goes unsolved — not by you, at least.", + }, + "quest.derelict.step1": { + "Внутри — пусто и холодно, но на мостике всё ещё гудит бортовой компьютер: неизвестная, но явно рабочая система ИИ, готовая к диалогу. Она предлагает добровольно перейти в вашу навигационную систему в обмен на не-уничтожение.", + "Inside, it's empty and cold, but the bridge computer is still humming: an unfamiliar but clearly functional AI system, ready to talk. It offers to voluntarily transfer into your navigation system in exchange for not being destroyed.", + }, + "quest.derelict.choice1.0": {"Подключить ИИ к своим системам", "Connect the AI to your own systems"}, + "quest.derelict.choice1.1": {"Стереть систему и просто разобрать корпус на металл", "Wipe the system and just strip the hull for scrap"}, + "quest.derelict.step2": { + "ИИ оказывается на удивление полезным штурманом — с первого же прыжка он подсказывает более экономный маршрут, о котором вы не подозревали. Впрочем, иногда вам кажется, что он изучает не только звёздные карты, но и вас самих.", + "The AI turns out to be a surprisingly useful navigator — from the very first jump it points out a more efficient route you hadn't considered. Still, sometimes you get the feeling it's studying more than star charts — it's studying you too.", + }, + "quest.derelict.choice2.0": {"Оставить ИИ на борту — риск того стоит", "Keep the AI aboard — the risk is worth it"}, + "quest.derelict.result2.0": { + "Груз данных, извлечённых ИИ из архивов старого корабля, оказывается неожиданно ценным для торговой гильдии. Крупная выплата — но по ночам вам иногда кажется, что кто-то смотрит на показания приборов через ваше же плечо.", + "The data the AI extracted from the old ship's archives turns out to be unexpectedly valuable to the trade guild. A large payout follows — though some nights you swear something is watching the instrument readings over your shoulder.", + }, + "quest.derelict.step3": { + "Вы стираете систему без колебаний и разбираете уцелевшие узлы на пригодные для продажи детали. Спокойно, безопасно, без сюрпризов.", + "You wipe the system without hesitation and strip the surviving components for salvage. Calm, safe, no surprises.", + }, + "quest.derelict.choice3.0": {"Продать металлолом на ближайшей станции", "Sell the scrap at the nearest station"}, + "quest.derelict.result3.0": { + "Скромная, но честная прибыль от продажи металла и уцелевших деталей. Никаких загадок, никаких сюрпризов — и вы засыпаете спокойно.", + "A modest but honest profit from the scrap metal and surviving parts. No mysteries, no surprises — and you sleep soundly.", + }, + + // --- 4. Груз для мятежников --- + "quest.smuggler.title": {"Груз для мятежников", "Cargo for the Rebellion"}, + "quest.smuggler.brief": {"Решить, на чьей вы стороне в чужой войне", "Decide whose side you're on in someone else's war"}, + "quest.smuggler.step0": { + "В баре станции к вам подсаживается женщина в потрёпанной куртке: повстанцы просят перевезти опечатанный груз через систему, контролируемую местной диктатурой. Что внутри, она не говорит. Плата щедрая — рискованно щедрая.", + "A woman in a worn jacket sits down next to you at the station bar: the rebels need someone to run a sealed cargo through a system controlled by the local dictatorship. She won't say what's inside. The pay is generous — suspiciously generous.", + }, + "quest.smuggler.choice0.0": {"Взять груз и лететь", "Take the cargo and fly"}, + "quest.smuggler.choice0.1": {"Отказаться, но взять плату просто за молчание", "Decline, but take payment for keeping quiet"}, + "quest.smuggler.choice0.2": {"Сдать её властям системы", "Report her to the system authorities"}, + "quest.smuggler.result0.1": { + "Вы забираете деньги за молчание и уходите. Куда делся груз и повстанка — уже не ваша забота.", + "You take the hush money and walk away. Where the cargo and the rebel went from there is no longer your concern.", + }, + "quest.smuggler.result0.2": { + "Власти благодарят за бдительность, но сумма вознаграждения оказывается меньше, чем предлагали повстанцы, а сама повстанка исчезает в коридорах станции ещё до прибытия патруля — доносить оказалось не так выгодно, как казалось.", + "The authorities thank you for your vigilance, but the reward turns out to be smaller than the rebels offered, and the woman vanishes into the station's corridors before the patrol even arrives — informing wasn't as profitable as it seemed.", + }, + "quest.smuggler.step1": { + "На подлёте к системе диктатуры патрульный корвет требует остановиться для досмотра груза. Опечатанный ящик в трюме внезапно ощущается очень заметным.", + "Approaching the dictatorship's system, a patrol corvette orders you to stop for a cargo inspection. The sealed crate in your hold suddenly feels very conspicuous.", + }, + "quest.smuggler.choice1.0": {"Предложить патрулю щедрую взятку", "Offer the patrol a generous bribe"}, + "quest.smuggler.choice1.1": {"Прорываться силой", "Force your way through"}, + "quest.smuggler.step2": { + "Офицер патруля долго смотрит на протянутые кредиты, потом на вас, потом убирает сканер обратно в кобуру. \"Не видел вас сегодня\", — говорит он и разворачивает корвет.", + "The patrol officer stares at the offered credits for a long moment, then at you, then holsters the scanner. \"Didn't see you today,\" he says, and turns the corvette away.", + }, + "quest.smuggler.choice2.0": {"Продолжить путь к точке назначения", "Continue on to the delivery point"}, + "quest.smuggler.result2.0": { + "Груз доставлен в руки подпольной ячейки повстанцев без единого выстрела. Позже вы узнаёте из обрывков новостей, что содержимое ящика — медикаменты для подпольного госпиталя, а вовсе не оружие, которого вы опасались.", + "The cargo reaches the rebel cell's hands without a single shot fired. Later, from scraps of news, you learn the crate held medical supplies for an underground hospital — not the weapons you'd been dreading.", + }, + "quest.smuggler.step3": { + "Лазеры корвета бьют по корпусу прежде, чем вы успеваете форсировать двигатели, но брешь в патрульной линии находится, и вы вырываетесь в открытый космос на честном слове и остатках обшивки.", + "The corvette's lasers rake your hull before you can even punch the engines, but you find a gap in the patrol line and break for open space, held together by sheer luck and what's left of your armor plating.", + }, + "quest.smuggler.choice3.0": {"Уйти в гиперпрыжок, пока не поздно", "Jump to hyperspace before it's too late"}, + "quest.smuggler.result3.0": { + "Груз доставлен, хоть и куда более драматичным способом, чем хотелось бы. Повстанцы платят щедрую премию за пробитую блокаду — оказывается, ваш прорыв уже стал легендой в их радиосети.", + "The cargo is delivered, though in a far more dramatic fashion than you'd have liked. The rebels pay a generous bonus for running the blockade — it turns out your breakout is already a legend on their radio network.", + }, + } { + translations[k] = v + } +} diff --git a/translations_wayfarer3.go b/translations_wayfarer3.go new file mode 100644 index 0000000..44c34db --- /dev/null +++ b/translations_wayfarer3.go @@ -0,0 +1,174 @@ +package main + +func init() { + for k, v := range map[string]translationEntry{ + // --- 5. Первый контакт --- + "quest.contact.title": {"Первый контакт", "First Contact"}, + "quest.contact.brief": {"Установить контакт с неизвестной формой жизни", "Make contact with an unknown lifeform"}, + "quest.contact.step0": { + "На пустынной окраине системы наперерез вашему курсу выходит корабль совершенно незнакомой конструкции. Никаких опознавательных сигналов, никаких известных протоколов связи — только тишина и медленное сближение.", + "On the empty edge of the system, a ship of completely unfamiliar design crosses your course. No recognizable transponder signals, no known communication protocols — only silence and a slow approach.", + }, + "quest.contact.choice0.0": {"Предложить часть груза как жест доброй воли", "Offer part of your cargo as a gesture of goodwill"}, + "quest.contact.choice0.1": {"Попытаться наладить контакт через универсальный переводчик", "Try to establish contact through the universal translator"}, + "quest.contact.choice0.2": {"Развернуться и уйти на форсаже", "Turn around and burn for distance"}, + "quest.contact.result0.2": { + "Вы уходите прочь, так и не узнав, кем были ваши несостоявшиеся собеседники. Корабль не преследует — просто продолжает медленно дрейфовать там, где вы его оставили.", + "You pull away, never learning who your would-be counterparts were. The ship doesn't pursue — it simply keeps drifting slowly where you left it.", + }, + "quest.contact.step1": { + "Вы выпускаете в пространство между кораблями контейнер с частью груза. После долгой паузы чужой корабль подходит ближе и аккуратно забирает подарок — а затем в ответ передаёт пакет данных: координаты, судя по всему, безопасного скопления ресурсов неподалёку.", + "You release a container with part of your cargo into the space between the ships. After a long pause, the alien vessel approaches and carefully retrieves the gift — then transmits a data packet in return: coordinates, apparently, of a safe resource cluster nearby.", + }, + "quest.contact.choice1.0": {"Принять дар с благодарностью и разойтись с миром", "Accept the gift gratefully and part ways in peace"}, + "quest.contact.result1.0": { + "Координаты оказываются точными — там действительно скрывается богатое скопление ценных минералов, ранее не отмеченное ни на одной карте. Первый контакт прошёл мирно, и об этом вам будет что рассказать.", + "The coordinates turn out to be accurate — a rich cluster of valuable minerals really is hidden there, unmarked on any chart before now. First contact went peacefully, and it's a story worth telling.", + }, + "quest.contact.step2": { + "Вы включаете универсальный переводчик и начинаете медленно, терпеливо обмениваться простыми сигналами — числами, светом, паттернами. Прогресс есть, но он мучительно медленный, а корабль тем временем продолжает сближаться.", + "You activate the universal translator and begin slowly, patiently exchanging simple signals — numbers, light, patterns. There's progress, but it's agonizingly slow, and the ship keeps closing the distance all the while.", + }, + "quest.contact.choice2.0": {"Продолжать терпеливо, несмотря на риск", "Keep at it patiently, despite the risk"}, + "quest.contact.choice2.1": {"Прервать попытку и приготовиться к обороне", "Break off the attempt and prepare to defend yourself"}, + "quest.contact.result2.1": { + "Резкий обрыв связи чужой корабль воспринимает как угрозу и открывает огонь первым. Вы отбиваетесь и уходите с повреждениями, так и не узнав, что пытались сказать вам эти существа.", + "The alien ship reads your abrupt disengagement as a threat and opens fire first. You fight your way clear, damaged, never learning what those beings were trying to tell you.", + }, + "quest.contact.step3": { + "Наконец паттерны складываются во что-то осмысленное: чужой корабль не враждебен — это исследовательский зонд, посланный узнать, разумны ли обитатели этого сектора вообще. Похоже, вы только что прошли своеобразный экзамен.", + "Finally, the patterns resolve into something meaningful: the alien ship isn't hostile at all — it's a research probe, sent to determine whether the inhabitants of this sector are even sentient. It seems you've just passed a kind of exam.", + }, + "quest.contact.choice3.0": {"Обменяться данными на прощание", "Exchange data as a parting gesture"}, + "quest.contact.result3.0": { + "Обмен данными скромный, но исторический: это первое подтверждённое общение с разумной формой жизни за пределами известного пространства. Научные круги готовы неплохо заплатить за запись этого контакта.", + "The data exchange is modest but historic: this is the first confirmed communication with a sentient lifeform beyond known space. Scientific circles are willing to pay well for the recording of this contact.", + }, + + // --- 6. Охота за головой --- + "quest.bounty.title": {"Охота за головой", "Bounty Hunt"}, + "quest.bounty.brief": {"Выследить и остановить пирата-рецидивиста", "Track down and stop a repeat-offender pirate"}, + "quest.bounty.step0": { + "На доске объявлений станции — щедрая награда за поимку капитана Дрейка Вейла, пирата, терроризирующего торговые пути уже не первый год. Последний раз его корабль видели у астероидного поля в соседней системе.", + "On the station's bounty board: a generous reward for the capture of Captain Drake Vale, a pirate who's been terrorizing trade routes for years. His ship was last seen near an asteroid field in the neighboring system.", + }, + "quest.bounty.choice0.0": {"Отправиться на поиски", "Set out to find him"}, + "quest.bounty.choice0.1": {"Не связываться — слишком опасно", "Not worth it — too dangerous"}, + "quest.bounty.result0.1": { + "Вы решаете, что награда не стоит риска. Капитан Вейл остаётся чужой проблемой ещё какое-то время.", + "You decide the reward isn't worth the risk. Captain Vale remains someone else's problem for a while longer.", + }, + "quest.bounty.step1": { + "Среди обломков астероидного поля вы находите скрытую базу — судя по количеству кораблей на подлёте, это не одиночка, а целая банда. Прямая атака рискованна, но есть и другой путь: анонимно слить координаты патрульным силам.", + "Among the wreckage of the asteroid field you find a hidden base — judging by the number of ships coming and going, this isn't a lone wolf but an entire gang. A direct assault is risky, but there's another way: anonymously leak the coordinates to the patrol forces.", + }, + "quest.bounty.choice1.0": {"Атаковать лично", "Attack personally"}, + "quest.bounty.choice1.1": {"Слить координаты патрулю и остаться в стороне", "Leak the coordinates to the patrol and stay out of it"}, + "quest.bounty.step2": { + "Бой тяжёлый — Вейл опытнее, чем ожидалось, но в конце концов его корабль замолкает навсегда. Обыскав обломки, вы находите достаточно улик, чтобы подтвердить личность цели.", + "The fight is brutal — Vale is more skilled than expected, but in the end his ship falls silent for good. Searching the wreckage, you find enough evidence to confirm the target's identity.", + }, + "quest.bounty.choice2.0": {"Забрать награду лично", "Claim the bounty in person"}, + "quest.bounty.result2.0": { + "Награда за голову Вейла — самая крупная выплата в вашей карьере на текущий момент. Ваше имя начинает появляться в разговорах других пилотов на станциях.", + "The bounty on Vale's head is the largest payout of your career so far. Your name starts coming up in conversations among other pilots at the stations.", + }, + "quest.bounty.step3": { + "Патрульные силы благодарят за наводку и берут базу штурмом без вашего участия. Вейла берут живым для показательного суда — награда за поимку меньше, чем была бы за уничтожение, но и риска для вас не было вовсе.", + "The patrol forces thank you for the tip and storm the base without your involvement. Vale is taken alive for a show trial — the reward for the tip is smaller than it would have been for a kill, but you took on no risk at all.", + }, + "quest.bounty.choice3.0": {"Забрать причитающуюся долю награды", "Collect your share of the reward"}, + "quest.bounty.result3.0": { + "Скромная, но безопасная выплата за наводку. Патрульные капитаны запоминают ваше имя как надёжный источник информации — не самая громкая слава, зато честная.", + "A modest but safe payout for the tip-off. Patrol captains remember your name as a reliable source of information — not the loudest kind of fame, but an honest one.", + }, + + // --- 7. Карантин --- + "quest.plague.title": {"Карантин", "Quarantine"}, + "quest.plague.brief": {"Доставить лекарство на шахтёрскую станцию в карантине", "Deliver medicine to a quarantined mining station"}, + "quest.plague.step0": { + "Шахтёрская станция объявила карантин: неизвестный патоген косит рабочих быстрее, чем местный медотсек успевает лечить. Медикаменты есть на соседней станции, но никто из местных перевозчиков не рискует лететь в зону карантина.", + "A mining station has declared quarantine: an unknown pathogen is taking down workers faster than the local medbay can treat them. Medicine is available at a neighboring station, but none of the local haulers will risk flying into the quarantine zone.", + }, + "quest.plague.choice0.0": {"Забрать медикаменты и лететь в зону карантина", "Pick up the medicine and fly into the quarantine zone"}, + "quest.plague.choice0.1": {"Слишком рискованно для собственного здоровья — отказаться", "Too big a health risk — decline"}, + "quest.plague.result0.1": { + "Вы решаете не рисковать заражением. Как справилась станция без лекарства, вы узнаёте только из скупой заметки в новостях через пару недель.", + "You decide not to risk infection. How the station managed without the medicine, you only learn from a terse news item a couple of weeks later.", + }, + "quest.plague.step1": { + "На подлёте к станции с вами связывается делец местного чёрного рынка: он предлагает выкупить у вас лекарство прямо на орбите — с большой наценкой, разумеется, — и перепродать его станции самостоятельно.", + "Approaching the station, a local black-market dealer contacts you: he offers to buy the medicine from you right there in orbit — at a hefty markup, of course — and resell it to the station himself.", + }, + "quest.plague.choice1.0": {"Отказаться и отдать лекарство станции напрямую и бесплатно", "Refuse and give the medicine to the station directly, free of charge"}, + "quest.plague.choice1.1": {"Согласиться — пусть перекупщик решает вопрос цены со станцией сам", "Agree — let the dealer sort out the price with the station himself"}, + "quest.plague.step2": { + "Медотсек станции принимает груз с явным облегчением. Главный врач лично благодарит вас перед всем персоналом — не самая большая плата, зато совесть чиста.", + "The station's medbay receives the shipment with visible relief. The chief physician personally thanks you in front of the entire staff — not the biggest payday, but a clean conscience.", + }, + "quest.plague.choice2.0": {"Принять благодарность станции", "Accept the station's gratitude"}, + "quest.plague.result2.0": { + "Скромная компенсация за топливо и хлопоты, зато станция помнит имя того, кто помог им в трудную минуту не ради наживы.", + "A modest reimbursement for fuel and trouble, but the station remembers the name of the one who helped in their darkest hour without trying to profit from it.", + }, + "quest.plague.step3": { + "Перекупщик забирает груз и, судя по донёсшимся позже слухам, продаёт лекарство станции по цене, которую многие семьи шахтёров едва могут себе позволить. Вы уже далеко и своей доли не упускаете.", + "The dealer takes the shipment and, according to rumors that reach you later, sells the medicine to the station at a price many miners' families can barely afford. You're already long gone, and you don't miss out on your cut.", + }, + "quest.plague.choice3.0": {"Забрать свою долю у перекупщика", "Collect your cut from the dealer"}, + "quest.plague.result3.0": { + "Самая крупная разовая выплата за всю эту доставку — но в разговорах на станциях сектора ваше имя теперь иногда произносят не с благодарностью, а с горечью.", + "The largest single payout from this whole delivery — but in conversations across the sector's stations, your name is now sometimes spoken not with gratitude, but with bitterness.", + }, + + // --- 8. Находка предтеч --- + "quest.artifact.title": {"Находка предтеч", "Precursor Find"}, + "quest.artifact.brief": {"Решить судьбу артефакта древней цивилизации", "Decide the fate of an artifact from an ancient civilization"}, + "quest.artifact.step0": { + "Сканируя необитаемую луну на отшибе системы, вы натыкаетесь на явно искусственную структуру, вросшую в породу миллионы лет назад. Внутри — нетронутый артефакт неизвестного назначения, покрытый письменами, каких прежде никто не видел.", + "Scanning an uninhabited moon at the edge of the system, you stumble upon a clearly artificial structure, embedded in the rock millions of years ago. Inside: an untouched artifact of unknown purpose, covered in writing no one has ever seen before.", + }, + "quest.artifact.choice0.0": {"Забрать артефакт с собой", "Take the artifact with you"}, + "quest.artifact.choice0.1": {"Оставить на месте — не стоит тревожить древности", "Leave it be — some antiquities shouldn't be disturbed"}, + "quest.artifact.result0.1": { + "Вы отмечаете координаты и улетаете, оставив находку нетронутой. Возможно, кто-то другой найдёт её позже — а может, она пролежит там ещё миллион лет.", + "You mark the coordinates and fly off, leaving the find undisturbed. Perhaps someone else will find it later — or perhaps it will lie there for another million years.", + }, + "quest.artifact.step1": { + "Слух о находке расходится быстрее, чем хотелось бы. К вам обращаются сразу трое: куратор небольшого музея, частный коллекционер с сомнительной репутацией и представитель правительства с ещё более сомнительными полномочиями.", + "News of the find spreads faster than you'd like. Three parties reach out at once: the curator of a small museum, a private collector with a dubious reputation, and a government representative with even more dubious authority.", + }, + "quest.artifact.choice1.0": {"Продать музею — пусть все увидят", "Sell to the museum — let everyone see it"}, + "quest.artifact.choice1.1": {"Продать коллекционеру — он предлагает больше всех", "Sell to the collector — he's offering the most"}, + "quest.artifact.choice1.2": {"Передать правительству в обмен на привилегии", "Hand it to the government in exchange for privileges"}, + "quest.artifact.step2": { + "Куратор музея едва сдерживает слёзы радости, разворачивая находку перед камерами. Артефакт займёт центральное место в новой галерее, а ваше имя навсегда останется в табличке рядом с экспонатом.", + "The museum curator can barely hold back tears of joy unveiling the find in front of the cameras. The artifact will take center stage in the new gallery, and your name will forever remain on the placard beside it.", + }, + "quest.artifact.choice2.0": {"Присутствовать на открытии выставки", "Attend the exhibit's opening"}, + "quest.artifact.result2.0": { + "Плата от музея скромная, но слава первооткрывателя разлетается по всему сектору. Учёные годами будут ссылаться на находку с вашим именем в сносках.", + "The museum's payment is modest, but fame as the discoverer spreads across the whole sector. Scholars will cite the find with your name in the footnotes for years to come.", + }, + "quest.artifact.step3": { + "Коллекционер расплачивается без вопросов и тут же увозит артефакт в частное хранилище, куда не попадёт больше ни один учёный. Деньги реальны — но находка исчезает из истории так же внезапно, как появилась в ней.", + "The collector pays without questions and immediately whisks the artifact away to a private vault no scholar will ever access again. The money is real — but the find vanishes from history as suddenly as it entered it.", + }, + "quest.artifact.choice3.0": {"Забрать деньги и не оглядываться", "Take the money and don't look back"}, + "quest.artifact.result3.0": { + "Самая крупная единовременная выплата, которую вы когда-либо получали. Об артефакте больше никто и никогда не услышит.", + "The largest lump-sum payment you've ever received. No one will ever hear about the artifact again.", + }, + "quest.artifact.step4": { + "Правительственный представитель не торгуется — предлагает щедрую сумму и статус доверенного поставщика для будущих находок в обмен на полную конфиденциальность. Что они собираются делать с артефактом, вам не сообщают.", + "The government representative doesn't haggle — he offers a generous sum and trusted-supplier status for future finds, in exchange for total confidentiality. What they intend to do with the artifact, you're not told.", + }, + "quest.artifact.choice4.0": {"Принять условия", "Accept the terms"}, + "quest.artifact.result4.0": { + "Хорошая плата и обещание выгодных контрактов в будущем — но артефакт исчезает в засекреченном хранилище, и что там на самом деле написано на его поверхности, вы, возможно, не узнаете уже никогда.", + "Good pay and the promise of lucrative contracts down the line — but the artifact disappears into a classified vault, and what its surface actually says, you may never find out.", + }, + } { + translations[k] = v + } +} diff --git a/translations_wayfarer4.go b/translations_wayfarer4.go new file mode 100644 index 0000000..f861bef --- /dev/null +++ b/translations_wayfarer4.go @@ -0,0 +1,157 @@ +package main + +// translationsWayfarerUI добавляется через init() — общие элементы +// интерфейса "Странника" (хаб станции, рынок, верфь, перелёт, бой, +// список заданий, конец карьеры). Тексты самих квестов — в +// translations_wayfarer1/2/3.go. +func init() { + for k, v := range map[string]translationEntry{ + "wf.title": {"=== СТРАННИК ===", "=== WAYFARER ==="}, + + "wf.status_line": { + "Кредиты: %s Груз: %d/%d Топливо: %.1f/%.1f Корпус: %d/%d Щиты: %d/%d", + "Credits: %s Cargo: %d/%d Fuel: %.1f/%.1f Hull: %d/%d Shields: %d/%d", + }, + "wf.system_line": {"Система: %s (%s, тех. уровень %d)", "System: %s (%s, tech level %d)"}, + "wf.rating_line": {"Рейтинг пилота: %s (побед: %d)", "Pilot rating: %s (kills: %d)"}, + + "wf.hub.hint1": {"[1] Рынок [2] Верфь [3] Перелёт [4] Задания", "[1] Market [2] Shipyard [3] Travel [4] Quests"}, + "wf.hub.hint2": {"Пристыкованы к станции.", "Docked at the station."}, + "wf.hint.quit": {"[q] сохранить и выйти в меню", "[q] save and quit to menu"}, + + "wf.market.title": {"=== РЫНОК ===", "=== MARKET ==="}, + "wf.market.line": {"%-22s цена: %8.1f в трюме: %-3d", "%-22s price: %8.1f held: %-3d"}, + "wf.market.bought_at": {" (куплено по: %.1f)", " (bought at: %.1f)"}, + "wf.market.illegal_tag": {"[запрещено]", "[illegal]"}, + "wf.market.hint": { + "[↑↓/kj] выбор товара [b] купить 1 [B] купить максимум [s] продать 1 [S] продать всё [q] назад", + "[↑↓/kj] choose commodity [b] buy 1 [B] buy max [s] sell 1 [S] sell all [q] back", + }, + + "wf.shipyard.title": {"=== ВЕРФЬ ===", "=== SHIPYARD ==="}, + "wf.shipyard.refuel": {"Дозаправить полный бак (цена: %.0f)", "Refuel to full (cost: %.0f)"}, + "wf.shipyard.repair": {"Починить корпус полностью (цена: %.0f)", "Repair hull fully (cost: %.0f)"}, + "wf.shipyard.available": {"доступно", "available"}, + "wf.shipyard.owned": {"уже установлено", "already installed"}, + "wf.shipyard.equip_line": { + "%-28s цена: %8.0f [%s]", + "%-28s cost: %8.0f [%s]", + }, + "wf.shipyard.hint": { + "[↑↓/kj] выбор [enter] купить/починить/дозаправить [q] назад", + "[↑↓/kj] choose [enter] buy/repair/refuel [q] back", + }, + + "wf.travel.title": {"=== ПЕРЕЛЁТ ===", "=== TRAVEL ==="}, + "wf.travel.range": {"Дальность на текущем запасе топлива: %.1f св. лет", "Range on current fuel: %.1f ly"}, + "wf.travel.none_in_range": {"Нет систем в пределах досягаемости.", "No systems within range."}, + "wf.travel.line": {"%-16s %-24s %.1f св. лет", "%-16s %-24s %.1f ly"}, + "wf.travel.galactic_jump": {"Межгалактический прыжок (нужен почти полный бак)", "Intergalactic jump (needs an almost full tank)"}, + "wf.travel.hint": {"[↑↓/kj] выбор [enter] лететь [q] назад", "[↑↓/kj] choose [enter] jump [q] back"}, + + "wf.combat.title": {"=== БОЙ ===", "=== COMBAT ==="}, + "wf.combat.enemy_line": {"%s — корпус: %d/%d", "%s — hull: %d/%d"}, + "wf.combat.player_line": {"Вы — корпус: %d/%d щиты: %d/%d", "You — hull: %d/%d shields: %d/%d"}, + "wf.combat.round_result": {"Вы нанесли %d урона, получили %d урона.", "You dealt %d damage, took %d damage."}, + "wf.combat.hint.attack": {"[1] Атаковать", "[1] Attack"}, + "wf.combat.hint.flee": {"[2] Бежать", "[2] Flee"}, + "wf.combat.hint.negotiate": {"[3] Договориться", "[3] Negotiate"}, + "wf.combat.outcome.won": {"Противник уничтожен!", "Enemy destroyed!"}, + "wf.combat.outcome.fled": {"Вы успешно сбежали.", "You successfully fled."}, + "wf.combat.outcome.negotiated": {"Удалось договориться.", "You reached an agreement."}, + "wf.combat.outcome.lost": {"Ваш корабль уничтожен...", "Your ship has been destroyed..."}, + + "wf.enemy.pirate": {"Пиратский корабль", "Pirate ship"}, + "wf.enemy.raider": {"Налётчик", "Raider"}, + + "wf.equip.fuelscoop": {"Топливный скиммер", "Fuel scoop"}, + "wf.equip.cargoexp": {"Расширение трюма", "Cargo bay expansion"}, + "wf.equip.shieldbooster": {"Усилитель щитов", "Shield booster"}, + "wf.equip.escapepod": {"Спасательная капсула", "Escape pod"}, + "wf.equip.betterlaser": {"Улучшенный лазер", "Improved laser"}, + + "wf.gameover.destroyed": {"Ваш корабль уничтожен. Карьера окончена.", "Your ship has been destroyed. Career over."}, + "wf.gameover.stats": {"Финальный рейтинг: %s (побед: %d)", "Final rating: %s (kills: %d)"}, + "wf.gameover.hint": {"[n] новая карьера [q] в меню", "[n] new career [q] menu"}, + + "wf.gov.anarchy": {"Анархия", "Anarchy"}, + "wf.gov.feudal": {"Феодализм", "Feudal"}, + "wf.gov.dictatorship": {"Диктатура", "Dictatorship"}, + "wf.gov.communist": {"Коммунизм", "Communist"}, + "wf.gov.confederacy": {"Конфедерация", "Confederacy"}, + "wf.gov.democracy": {"Демократия", "Democracy"}, + "wf.gov.corporate": {"Корпоративное государство", "Corporate state"}, + + "wf.msg.bought": {"Куплено: %d ед. (%s)", "Bought: %d units (%s)"}, + "wf.msg.sold": {"Продано: %d ед. (%s)", "Sold: %d units (%s)"}, + "wf.msg.refueled": {"Бак заправлен.", "Tank refueled."}, + "wf.msg.repaired": {"Корпус починен.", "Hull repaired."}, + "wf.msg.equipped": {"Снаряжение установлено.", "Equipment installed."}, + "wf.msg.galactic_jump": {"Межгалактический прыжок выполнен.", "Intergalactic jump complete."}, + "wf.msg.jump_quiet": {"Прыжок прошёл спокойно.", "The jump was uneventful."}, + "wf.msg.pirate_ambush": {"Из засады выходит пиратский корабль!", "A pirate ship ambushes you!"}, + "wf.msg.cargo_confiscated": {"Патруль конфисковал запрещённый груз на сумму %s кредитов.", "Patrol confiscated illegal cargo worth %s credits."}, + "wf.msg.scan_clear": {"Патруль проверил корабль — всё чисто.", "Patrol scanned your ship — all clear."}, + "wf.msg.distress_trap": {"Сигнал бедствия оказался ловушкой!", "The distress signal was a trap!"}, + "wf.msg.distress_reward": {"Вы помогли терпящим бедствие и получили %s кредитов.", "You helped the distressed ship and received %s credits."}, + "wf.msg.derelict_find": {"На обломках найдено %s кредитов.", "Found %s credits worth of salvage on the derelict."}, + "wf.msg.combat_won": {"Победа! Получено %s кредитов.", "Victory! You received %s credits."}, + "wf.msg.combat_fled": {"Вам удалось сбежать.", "You managed to flee."}, + "wf.msg.combat_negotiated": {"Удалось договориться и избежать боя.", "You negotiated your way out of the fight."}, + "wf.msg.escape_pod_used": {"Спасательная капсула сработала — корабль уцелел, но дорогой ценой.", "The escape pod activated — your ship survived, at a steep cost."}, + + "wf.quests.title": {"=== ЗАДАНИЯ ===", "=== QUESTS ==="}, + "wf.quests.active": {"Активный квест: %s", "Active quest: %s"}, + "wf.quests.hint.continue": {"[enter] продолжить [q] назад", "[enter] continue [q] back"}, + "wf.quests.hint.list": {"[q] назад", "[q] back"}, + "wf.quests.none_left": {"Больше доступных заданий нет.", "No more quests available."}, + "wf.quests.hint.back": {"[q] назад", "[q] back"}, + "wf.quests.hint.abandon": {"[q] прервать квест (без последствий)", "[q] abandon quest (no consequences)"}, + "wf.quests.progress": {"Выполнено заданий: %d из %d", "Quests completed: %d of %d"}, + "wf.quests.all_done": {"Все доступные задания выполнены. Продолжайте путешествовать!", "All available quests are complete. Keep traveling!"}, + "wf.quests.none_active": {"Сейчас нет активного задания. Отправляйтесь в путь — рано или поздно кто-то попросит о помощи.", "You have no active quest right now. Set out on your travels — sooner or later, someone will ask for your help."}, + "wf.quests.offer.title": {"=== ПРЕДЛОЖЕНИЕ ЗАДАНИЯ ===", "=== QUEST OFFER ==="}, + "wf.quests.offer.accept": {"[1] Взяться за задание", "[1] Accept the quest"}, + "wf.quests.offer.decline": {"[2] Отказаться", "[2] Decline"}, + "wf.quests.declined": {"Вы отказались от задания.", "You declined the quest."}, + + "rating.harmless": {"Безобиден", "Harmless"}, + "rating.mostly_harmless": {"Практически безобиден", "Mostly Harmless"}, + "rating.poor": {"Слабый пилот", "Poor"}, + "rating.average": {"Средний пилот", "Average"}, + "rating.above_average": {"Выше среднего", "Above Average"}, + "rating.competent": {"Опытный", "Competent"}, + "rating.dangerous": {"Опасен", "Dangerous"}, + "rating.deadly": {"Смертельно опасен", "Deadly"}, + "rating.wayfarer_elite": {"Элитный Странник", "Wayfarer Elite"}, + + "сейчас уже выполняется другой квест": {"сейчас уже выполняется другой квест", "another quest is already active"}, + "этот квест уже завершён или провален ранее": {"этот квест уже завершён или провален ранее", "this quest has already been completed or failed"}, + "квест с таким ключом не найден": {"квест с таким ключом не найден", "no quest found with that key"}, + "сейчас нет активного квеста": {"сейчас нет активного квеста", "there's no active quest right now"}, + "недопустимый номер варианта": {"недопустимый номер варианта", "invalid choice number"}, + "недостаточно кредитов": {"недостаточно кредитов", "not enough credits"}, + "трюм заполнен": {"трюм заполнен", "cargo hold is full"}, + "этого товара нет в трюме в таком количестве": {"этого товара нет в трюме в таком количестве", "you don't have that much of this commodity in your hold"}, + "не хватит топлива долететь так далеко": {"не хватит топлива долететь так далеко", "not enough fuel to reach that far"}, + "вы уже в этой системе": {"вы уже в этой системе", "you're already in that system"}, + "это снаряжение уже установлено": {"это снаряжение уже установлено", "this equipment is already installed"}, + "для межгалактического прыжка нужен полный (или почти полный) бак": {"для межгалактического прыжка нужен полный (или почти полный) бак", "an intergalactic jump needs a full (or nearly full) tank"}, + + "commodity.food": {"Продовольствие", "Food"}, + "commodity.textiles": {"Текстиль", "Textiles"}, + "commodity.machinery": {"Техника", "Machinery"}, + "commodity.computers": {"Компьютеры", "Computers"}, + "commodity.luxuries": {"Предметы роскоши", "Luxuries"}, + "commodity.medicine": {"Медикаменты", "Medicine"}, + "commodity.minerals": {"Минералы", "Minerals"}, + "commodity.rare_metals": {"Редкие металлы", "Rare metals"}, + "commodity.contraband": {"Контрабанда", "Contraband"}, + "commodity.firearms": {"Оружие", "Firearms"}, + + "game.wayfarer.name": {"Странник (Wayfarer)", "Wayfarer"}, + "game.wayfarer.desc": {"Текстовая космоторговля в духе Elite: галактика, торговля, пошаговый бой, 8 сюжетных квестов", "Text-based space trading inspired by Elite: a galaxy, trading, turn-based combat, 8 story quests"}, + } { + translations[k] = v + } +} diff --git a/tui.go b/tui.go index 0f65902..bb8aa12 100644 --- a/tui.go +++ b/tui.go @@ -21,7 +21,6 @@ var ( redCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) blackCardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) cursorStyle = lipgloss.NewStyle().Reverse(true).Bold(true) - stagedStyle = lipgloss.NewStyle().Underline(true).Bold(true) dimStyle = lipgloss.NewStyle().Faint(true) infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("87")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) @@ -58,7 +57,7 @@ func NewModel() Model { // перед началом игры. func NewModelWithStakes(startingCapital, ante, tonkMultiplier int) Model { botNames, botStrategies := newBotRoster(3) - names := append([]string{"Вы"}, botNames...) + names := append([]string{T("common.you")}, botNames...) game := NewGameWithStakes(names, 0, startingCapital, ante, tonkMultiplier) m := Model{ @@ -105,7 +104,7 @@ func (m *Model) setInfo(format string, args ...interface{}) { } func (m *Model) setError(err error) { - m.message = err.Error() + m.message = T(err.Error()) m.isError = true } @@ -131,10 +130,10 @@ func (m Model) handleBotMove() (tea.Model, tea.Cmd) { return m, nil } if err := bot.PlayTurn(m.game); err != nil { - m.setError(fmt.Errorf("бот %s: %w", m.game.Players[idx].Name, err)) + m.setError(fmt.Errorf(T("бот %s: %w"), m.game.Players[idx].Name, err)) return m, nil } - m.setInfo("%s сходил(а).", m.game.Players[idx].Name) + m.setInfo(T("tonk.msg.bot_moved"), m.game.Players[idx].Name) if m.game.Phase != PhaseGameOver && m.game.CurrentPlayer == m.humanIdx { m.resetSelectionState() } @@ -187,7 +186,7 @@ func (m Model) handleDrawKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы взяли карту из колоды.") + m.setInfo(T("tonk.msg.drew_deck")) m.resetSelectionState() case "p": card, err := m.game.DrawFromDiscard() @@ -195,8 +194,19 @@ func (m Model) handleDrawKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы взяли из сброса: %s", card) + m.setInfo(T("tonk.msg.drew_discard"), card) m.resetSelectionState() + case "k": + res, err := m.game.Drop() + if err != nil { + m.setError(err) + return m, nil + } + if res.DropCaught { + m.setInfo(T("tonk.msg.drop_caught"), m.game.Players[res.Winner].Name) + } else { + m.setInfo(T("tonk.msg.drop_success")) + } } return m, nil } @@ -231,7 +241,7 @@ func (m Model) handleActionKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы объявили Тонк!") + m.setInfo(T("tonk.msg.tonk_declared")) _ = res return m, m.maybeScheduleBot() case "enter": @@ -243,7 +253,7 @@ func (m Model) handleActionKey(key string) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Вы сбросили %s.", card) + m.setInfo(T("tonk.msg.discarded"), card) if m.game.Phase != PhaseGameOver { m.resetSelectionState() } @@ -257,7 +267,7 @@ func (m Model) handleActionKey(key string) (tea.Model, tea.Cmd) { func (m Model) handleLayMeld() (tea.Model, tea.Cmd) { if len(m.staged) < 3 { - m.setError(fmt.Errorf("выберите (пробел) минимум 3 карты для комбинации")) + m.setError(fmt.Errorf(T("выберите (пробел) минимум 3 карты для комбинации"))) return m, nil } cards := make([]Card, 0, len(m.staged)) @@ -268,7 +278,7 @@ func (m Model) handleLayMeld() (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Комбинация выложена на стол.") + m.setInfo(T("tonk.msg.meld_laid")) if m.game.Phase != PhaseGameOver { m.resetSelectionState() return m, nil @@ -282,7 +292,7 @@ func (m Model) handleAddToMeld(meldIdx int) (tea.Model, tea.Cmd) { return m, nil } if meldIdx < 0 || meldIdx >= len(m.game.Table) { - m.setError(fmt.Errorf("на столе нет комбинации №%d", meldIdx+1)) + m.setError(fmt.Errorf(T("на столе нет комбинации №%d"), meldIdx+1)) return m, nil } card := hand[m.cursor] @@ -290,7 +300,7 @@ func (m Model) handleAddToMeld(meldIdx int) (tea.Model, tea.Cmd) { m.setError(err) return m, nil } - m.setInfo("Карта %s подложена к комбинации №%d.", card, meldIdx+1) + m.setInfo(T("tonk.msg.card_added"), card, meldIdx+1) if m.game.Phase != PhaseGameOver { m.resetSelectionState() return m, nil @@ -349,13 +359,9 @@ func (m *Model) checkBankruptcies() string { if wasHuman { m.humanIdx = -1 - messages = append(messages, fmt.Sprintf( - "Вы обанкротились и выбыли! Ваше место занял новый игрок: %s. Дальше играют боты — можно наблюдать или [q] выйти в меню.", - newName)) + messages = append(messages, fmt.Sprintf(T("tonk.bankrupt.human"), newName)) } else { - messages = append(messages, fmt.Sprintf( - "%s обанкротился(лась) и выбыл(а). За стол сел новый игрок: %s (капитал %d).", - oldName, newName, p.Balance)) + messages = append(messages, fmt.Sprintf(T("tonk.bankrupt.bot"), oldName, newName, p.Balance)) } } return strings.Join(messages, " ") @@ -365,27 +371,43 @@ func isRed(s Suit) bool { return s == Hearts || s == Diamonds } -func renderCard(c Card) string { - style := blackCardStyle +// cardColorStyle возвращает базовый (только цвет) стиль карты, не +// применяя его — используется, когда нужно доклеить к цвету ещё +// какие-то атрибуты (подчёркивание и т.п.) одним стилем, вместо +// вложенных вызовов Render (что ломается в некоторых терминалах). +func cardColorStyle(c Card) lipgloss.Style { if isRed(c.Suit) { - style = redCardStyle + return redCardStyle } - return style.Render(c.String()) + return blackCardStyle +} + +func renderCard(c Card) string { + return cardColorStyle(c).Render(c.String()) } func (m Model) renderHand() string { hand := m.game.current().Hand if len(hand) == 0 { - return dimStyle.Render("(нет карт)") + return dimStyle.Render(T("common.no_cards")) } parts := make([]string, len(hand)) for i, c := range hand { - text := renderCard(c) - if m.staged[c] { - text = stagedStyle.Render(text) - } - if i == m.cursor { + var text string + switch { + case i == m.cursor: + // курсор — поверх обычного (нецветного) текста карты, + // как и раньше text = cursorStyle.Render(c.String()) + case m.staged[c]: + // важно: цвет и подчёркивание в ОДНОМ стиле, а не + // Render() поверх уже отрендеренной (цветной) строки — + // вложенные ANSI-последовательности ломаются в + // некоторых терминалах (вложенный сброс обрывает + // внешний стиль раньше времени) + text = cardColorStyle(c).Underline(true).Bold(true).Render(c.String()) + default: + text = renderCard(c) } parts[i] = text } @@ -394,19 +416,19 @@ func (m Model) renderHand() string { func (m Model) renderTable() string { if len(m.game.Table) == 0 { - return dimStyle.Render("(стол пуст)") + return dimStyle.Render(T("tonk.table_empty")) } var b strings.Builder for i, meld := range m.game.Table { - kind := "Сет" + kind := T("tonk.set") if meld.Kind == KindSequence { - kind = "Сиквенс" + kind = T("tonk.sequence") } cardsStr := make([]string, len(meld.Cards)) for j, c := range meld.Cards { cardsStr[j] = renderCard(c) } - fmt.Fprintf(&b, " [%d] %s (%s): %s\n", i+1, kind, + fmt.Fprintf(&b, T("tonk.meld_line"), i+1, kind, m.game.Players[meld.Owner].Name, strings.Join(cardsStr, " ")) } return strings.TrimRight(b.String(), "\n") @@ -420,10 +442,7 @@ func (m Model) renderPlayers() string { marker = "» " } name := p.Name - if i == m.humanIdx { - name += " (вы)" - } - line := fmt.Sprintf("%s%-24s карт: %-2d капитал: %d", marker, name, len(p.Hand), p.Balance) + line := fmt.Sprintf(T("tonk.player_line"), marker, name, len(p.Hand), p.Balance) if i == m.game.CurrentPlayer { line = turnStyle.Render(line) } @@ -434,11 +453,11 @@ func (m Model) renderPlayers() string { func (m Model) View() string { if m.quitting { - return "До встречи!\n" + return T("common.goodbye") } var b strings.Builder - fmt.Fprintln(&b, titleStyle.Render("=== ТОНК ===")) + fmt.Fprintln(&b, titleStyle.Render(T("tonk.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, m.renderPlayers()) fmt.Fprintln(&b) @@ -448,38 +467,38 @@ func (m Model) View() string { if ok { topStr = renderCard(top) } - fmt.Fprintf(&b, "Колода: %d карт Сброс сверху: %s Банк раунда: %d (ставка: %d, Тонк ×%d)\n\n", + fmt.Fprintf(&b, T("tonk.status_line"), m.game.Deck.Len(), topStr, m.game.Pot, m.game.AnteAmount, m.game.TonkMultiplier) - fmt.Fprintln(&b, "Комбинации на столе:") + fmt.Fprintln(&b, T("tonk.melds_label")) fmt.Fprintln(&b, m.renderTable()) fmt.Fprintln(&b) if m.game.Phase == PhaseGameOver { fmt.Fprintln(&b, m.renderRoundResult()) fmt.Fprintln(&b) - fmt.Fprintln(&b, dimStyle.Render("[n] новый раунд [q] в меню")) + fmt.Fprintln(&b, dimStyle.Render(T("tonk.hint.gameover"))) return b.String() } if m.game.CurrentPlayer == m.humanIdx { - fmt.Fprintln(&b, "Ваша рука:") + fmt.Fprintln(&b, T("common.your_hand")) fmt.Fprintln(&b, m.renderHand()) - fmt.Fprintf(&b, "Вес руки: %d\n\n", HandValue(m.game.current().Hand)) + fmt.Fprintf(&b, T("tonk.hand_weight"), HandValue(m.game.current().Hand)) switch m.game.Phase { case PhaseDraw: - fmt.Fprintln(&b, "Ваш ход. [d] взять из колоды [p] взять из сброса ("+topStr+") [q] в меню") + fmt.Fprintln(&b, Tf("tonk.hint.draw", topStr)) case PhaseAction: tonkHint := "" if CanDeclareTonk(m.game.current().Hand, m.game.TonkThreshold) { - tonkHint = winStyle.Render(" [t] Тонк доступен!") + tonkHint = winStyle.Render(T("tonk.hint.tonk_available")) } - fmt.Fprintln(&b, "[←→/hl] выбор карты [space] отметить для комбинации [m] выложить комбинацию") - fmt.Fprintln(&b, "[1-9] подложить текущую карту к комбинации №N [enter] сбросить текущую карту"+tonkHint) + fmt.Fprintln(&b, T("tonk.hint.action1")) + fmt.Fprintln(&b, T("tonk.hint.action2")+tonkHint) } } else { - fmt.Fprintf(&b, "%s думает...\n", m.game.Players[m.game.CurrentPlayer].Name) + fmt.Fprintf(&b, T("common.thinking"), m.game.Players[m.game.CurrentPlayer].Name) } fmt.Fprintln(&b) @@ -499,16 +518,19 @@ func (m Model) renderRoundResult() string { var b strings.Builder switch { case res.DeadRound: - fmt.Fprintln(&b, errorStyle.Render(fmt.Sprintf("Раунд закончился вничью: колода и сброс исчерпаны. Банк (%d) возвращён игрокам.", res.Pot))) + fmt.Fprintln(&b, errorStyle.Render(Tf("tonk.result.dead", res.Pot))) case res.TonkCall: - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s объявил(а) Тонк и забрал(а) банк ×%d (%d)!", + fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.tonk", m.game.Players[res.Winner].Name, m.game.TonkMultiplier, res.Pot*m.game.TonkMultiplier))) + case res.DropCall && res.DropCaught: + fmt.Fprintln(&b, errorStyle.Render(Tf("tonk.result.drop_caught", m.game.Players[res.Winner].Name))) + case res.DropCall: + fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.drop_success", m.game.Players[res.Winner].Name, res.Pot))) default: - fmt.Fprintln(&b, winStyle.Render(fmt.Sprintf("%s избавился(лась) от всех карт и забрал(а) банк (%d)!", - m.game.Players[res.Winner].Name, res.Pot))) + fmt.Fprintln(&b, winStyle.Render(Tf("tonk.result.win", m.game.Players[res.Winner].Name, res.Pot))) } for i, p := range m.game.Players { - fmt.Fprintf(&b, " %-24s изменение капитала: %+d (итого: %d)\n", p.Name, res.BalanceDeltas[i], p.Balance) + fmt.Fprintf(&b, T("tonk.result.delta_line"), p.Name, res.BalanceDeltas[i], p.Balance) } return strings.TrimRight(b.String(), "\n") } diff --git a/tui_test.go b/tui_test.go index f46b7a8..ad63a65 100644 --- a/tui_test.go +++ b/tui_test.go @@ -134,6 +134,56 @@ func TestTUIAddToMeld(t *testing.T) { } } +func TestTUIDropSucceeds(t *testing.T) { + m := NewModel() + m.game.Players[0].Hand = []Card{c(Two, Clubs)} + m.game.Players[1].Hand = []Card{c(King, Hearts)} + m.game.Players[2].Hand = []Card{c(Queen, Spades)} + m.game.Players[3].Hand = []Card{c(King, Diamonds)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseDraw + + next, _ := m.Update(key("k")) + m = next.(Model) + if m.isError { + t.Fatalf("неожиданная ошибка дропа: %s", m.message) + } + if m.game.Phase != PhaseGameOver || !m.game.Result.DropCall || m.game.Result.DropCaught { + t.Fatalf("ожидался успешный дроп: %+v", m.game.Result) + } +} + +func TestTUIDropCaught(t *testing.T) { + m := NewModel() + m.game.Players[0].Hand = []Card{c(King, Hearts)} + m.game.Players[1].Hand = []Card{c(Ace, Clubs)} // реально ниже + m.game.Players[2].Hand = []Card{c(King, Spades)} + m.game.Players[3].Hand = []Card{c(King, Diamonds)} + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseDraw + + next, _ := m.Update(key("k")) + m = next.(Model) + if m.isError { + t.Fatalf("неожиданная ошибка дропа: %s", m.message) + } + if !m.game.Result.DropCaught { + t.Fatalf("ожидался неудачный дроп (попался)") + } +} + +func TestTUIDropOnlyInDrawPhase(t *testing.T) { + m := NewModel() + m.game.CurrentPlayer = 0 + m.game.Phase = PhaseAction + + next, _ := m.Update(key("k")) + m = next.(Model) + if m.game.Phase == PhaseGameOver { + t.Fatalf("дроп после взятия карты должен быть запрещён — клавиша 'k' здесь не должна ни на что влиять") + } +} + func TestTUIDeclareTonk(t *testing.T) { m := NewModel() m.game.Players[0].Hand = []Card{c(Ace, Clubs), c(Two, Hearts)} diff --git a/wayfarer_combat.go b/wayfarer_combat.go new file mode 100644 index 0000000..e7c614c --- /dev/null +++ b/wayfarer_combat.go @@ -0,0 +1,165 @@ +package main + +import "math/rand" + +// WayfarerCombatOutcome — текущий/итоговый исход боя. +type WayfarerCombatOutcome int + +const ( + CombatOngoing WayfarerCombatOutcome = iota + CombatWon // враг уничтожен + CombatLost // корабль игрока уничтожен + CombatFled // игрок сбежал + CombatNegotiated // удалось договориться +) + +// Базовые вероятности успеха действий "бегство"/"переговоры" — в +// сумме с некоторой случайностью боя это делает обе опции реальными, +// но не гарантированными альтернативами прямому бою. +const ( + wayfarerFleeChance = 0.45 + wayfarerNegotiateChance = 0.35 +) + +// WayfarerCombatState — состояние одного пошагового боя. +type WayfarerCombatState struct { + EnemyNameKey string + EnemyLaser int + EnemyHull int + EnemyMaxHull int + + PlayerLaser int + PlayerHull int + PlayerMaxHull int + PlayerShield int + PlayerMaxShield int + + Negotiable bool // можно ли вообще пытаться договориться с этим противником + Round int + Outcome WayfarerCombatOutcome + + LastPlayerDamage int // урон, нанесённый игроком в последнем раунде (для лога) + LastEnemyDamage int // урон, полученный игроком в последнем раунде +} + +// NewWayfarerCombat создаёт новое состояние боя. +func NewWayfarerCombat(enemyNameKey string, enemyHull, enemyLaser int, playerHull, playerMaxHull, playerShield, playerMaxShield, playerLaser int, negotiable bool) *WayfarerCombatState { + return &WayfarerCombatState{ + EnemyNameKey: enemyNameKey, + EnemyHull: enemyHull, + EnemyMaxHull: enemyHull, + EnemyLaser: enemyLaser, + PlayerHull: playerHull, + PlayerMaxHull: playerMaxHull, + PlayerShield: playerShield, + PlayerMaxShield: playerMaxShield, + PlayerLaser: playerLaser, + Negotiable: negotiable, + } +} + +// rollDamage — базовый урон laser ± 30% случайного разброса. +func rollDamage(laser int, rnd *rand.Rand) int { + factor := 0.7 + rnd.Float64()*0.6 + dmg := int(float64(laser) * factor) + if dmg < 1 { + dmg = 1 + } + return dmg +} + +// applyDamage наносит урон dmg сначала по щиту, затем по корпусу +// (щит поглощает урон первым, как в большинстве похожих игр). +func applyDamage(shield, hull *int, dmg int) { + if *shield > 0 { + absorbed := dmg + if absorbed > *shield { + absorbed = *shield + } + *shield -= absorbed + dmg -= absorbed + } + if dmg > 0 { + *hull -= dmg + if *hull < 0 { + *hull = 0 + } + } +} + +// Attack — игрок атакует; если враг не уничтожен, враг атакует в +// ответ в тот же раунд. +func (c *WayfarerCombatState) Attack(rnd *rand.Rand) { + if c.Outcome != CombatOngoing { + return + } + c.Round++ + + playerDmg := rollDamage(c.PlayerLaser, rnd) + c.EnemyHull -= playerDmg + if c.EnemyHull < 0 { + c.EnemyHull = 0 + } + c.LastPlayerDamage = playerDmg + + if c.EnemyHull == 0 { + c.Outcome = CombatWon + c.LastEnemyDamage = 0 + return + } + + enemyDmg := rollDamage(c.EnemyLaser, rnd) + applyDamage(&c.PlayerShield, &c.PlayerHull, enemyDmg) + c.LastEnemyDamage = enemyDmg + if c.PlayerHull == 0 { + c.Outcome = CombatLost + } +} + +// Flee — попытка сбежать из боя. При неудаче враг наносит полный +// удар (без ответа игрока в этом раунде — сосредоточились на +// бегстве, не на атаке). +func (c *WayfarerCombatState) Flee(rnd *rand.Rand) bool { + if c.Outcome != CombatOngoing { + return false + } + c.Round++ + + if rnd.Float64() < wayfarerFleeChance { + c.Outcome = CombatFled + c.LastEnemyDamage = 0 + return true + } + + enemyDmg := rollDamage(c.EnemyLaser, rnd) + applyDamage(&c.PlayerShield, &c.PlayerHull, enemyDmg) + c.LastEnemyDamage = enemyDmg + if c.PlayerHull == 0 { + c.Outcome = CombatLost + } + return false +} + +// Negotiate — попытка договориться (доступна только если +// c.Negotiable). При неудаче враг наносит полный удар, как и при +// неудачном бегстве. +func (c *WayfarerCombatState) Negotiate(rnd *rand.Rand) bool { + if c.Outcome != CombatOngoing || !c.Negotiable { + return false + } + c.Round++ + + if rnd.Float64() < wayfarerNegotiateChance { + c.Outcome = CombatNegotiated + c.LastEnemyDamage = 0 + return true + } + + enemyDmg := rollDamage(c.EnemyLaser, rnd) + applyDamage(&c.PlayerShield, &c.PlayerHull, enemyDmg) + c.LastEnemyDamage = enemyDmg + if c.PlayerHull == 0 { + c.Outcome = CombatLost + } + return false +} diff --git a/wayfarer_combat_test.go b/wayfarer_combat_test.go new file mode 100644 index 0000000..ab16fc5 --- /dev/null +++ b/wayfarer_combat_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "math/rand" + "testing" +) + +func TestApplyDamageAbsorbedByShieldFirst(t *testing.T) { + shield, hull := 10, 50 + applyDamage(&shield, &hull, 6) + if shield != 4 || hull != 50 { + t.Errorf("урон должен был полностью уйти в щит: shield=%d hull=%d", shield, hull) + } +} + +func TestApplyDamageOverflowsToHull(t *testing.T) { + shield, hull := 5, 50 + applyDamage(&shield, &hull, 12) + if shield != 0 || hull != 43 { + t.Errorf("избыток урона должен был перейти в корпус: shield=%d hull=%d", shield, hull) + } +} + +func TestApplyDamageHullNeverNegative(t *testing.T) { + shield, hull := 0, 5 + applyDamage(&shield, &hull, 100) + if hull != 0 { + t.Errorf("корпус не должен уходить в минус, получено %d", hull) + } +} + +func TestAttackCanDefeatEnemy(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 5, 5, 100, 100, 50, 50, 100, true) + c.Attack(rnd) + if c.Outcome != CombatWon { + t.Fatalf("огромный урон игрока должен был сразу уничтожить слабого врага, получен исход %v (EnemyHull=%d)", + c.Outcome, c.EnemyHull) + } + if c.LastEnemyDamage != 0 { + t.Errorf("уничтоженный враг не должен успевать ответным ударом") + } +} + +func TestAttackEnemySurvivesAndRetaliates(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 1000, 5, 100, 100, 50, 50, 5, true) + beforeHull := c.PlayerHull + c.PlayerShield + c.Attack(rnd) + if c.Outcome != CombatOngoing { + t.Fatalf("бой должен был продолжиться, получен исход %v", c.Outcome) + } + afterHull := c.PlayerHull + c.PlayerShield + if afterHull >= beforeHull { + t.Errorf("выживший враг должен был нанести ответный урон") + } +} + +func TestAttackCanDefeatPlayer(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 1000, 500, 1, 100, 0, 50, 1, true) + c.Attack(rnd) + if c.Outcome != CombatLost { + t.Fatalf("слабый игрок против огромного урона должен был проиграть, получен исход %v (PlayerHull=%d)", + c.Outcome, c.PlayerHull) + } +} + +func TestFleeEventuallySucceedsOrFails(t *testing.T) { + successSeen, failSeen := false, false + for seed := int64(0); seed < 200 && !(successSeen && failSeen); seed++ { + rnd := rand.New(rand.NewSource(seed)) + c := NewWayfarerCombat("enemy.pirate", 1000, 5, 1000, 1000, 100, 100, 5, true) + if c.Flee(rnd) { + successSeen = true + } else { + failSeen = true + } + } + if !successSeen || !failSeen { + t.Errorf("ожидались оба исхода бегства среди множества попыток: успех=%v неудача=%v", successSeen, failSeen) + } +} + +func TestFailedFleeStillTakesDamage(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 1000, 20, 1000, 1000, 0, 0, 5, true) + for i := 0; i < 50 && c.Outcome == CombatOngoing; i++ { + fled := c.Flee(rnd) + if !fled { + if c.LastEnemyDamage <= 0 { + t.Errorf("неудачное бегство должно было нанести урон игроку") + } + return + } + } +} + +func TestNegotiateOnlyAvailableWhenNegotiable(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 1000, 5, 1000, 1000, 100, 100, 5, false) + before := *c + ok := c.Negotiate(rnd) + if ok { + t.Errorf("переговоры не должны быть доступны, если Negotiable=false") + } + if c.Round != before.Round { + t.Errorf("недоступная попытка переговоров не должна тратить раунд") + } +} + +func TestNegotiateEventuallySucceedsOrFails(t *testing.T) { + successSeen, failSeen := false, false + for seed := int64(0); seed < 200 && !(successSeen && failSeen); seed++ { + rnd := rand.New(rand.NewSource(seed)) + c := NewWayfarerCombat("enemy.pirate", 1000, 5, 1000, 1000, 100, 100, 5, true) + if c.Negotiate(rnd) { + successSeen = true + } else { + failSeen = true + } + } + if !successSeen || !failSeen { + t.Errorf("ожидались оба исхода переговоров среди множества попыток: успех=%v неудача=%v", successSeen, failSeen) + } +} + +func TestActionsAfterCombatOverAreNoOps(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + c := NewWayfarerCombat("enemy.pirate", 5, 5, 100, 100, 50, 50, 100, true) + c.Attack(rnd) + if c.Outcome != CombatWon { + t.Fatalf("бой должен был завершиться победой для настройки теста") + } + roundAfter := c.Round + c.Attack(rnd) + c.Flee(rnd) + c.Negotiate(rnd) + if c.Round != roundAfter { + t.Errorf("действия после завершения боя не должны ничего менять") + } +} diff --git a/wayfarer_economy.go b/wayfarer_economy.go new file mode 100644 index 0000000..f2cbf2b --- /dev/null +++ b/wayfarer_economy.go @@ -0,0 +1,56 @@ +package main + +import "math/rand" + +// WayfarerCommodity — один вид товара для торговли. +type WayfarerCommodity struct { + Key string + BasePrice float64 + IndustrialBias float64 // >0 — дешевле в промышленных системах, <0 — дешевле в аграрных + WealthBias float64 // >0 — дешевле в богатых системах, <0 — дешевле в бедных + Illegal bool // патрули иногда проверяют такой груз +} + +// WayfarerCommodities — список доступных для торговли товаров. +// Индекс в этом срезе используется как компактный идентификатор +// товара в трюме корабля. +var WayfarerCommodities = []WayfarerCommodity{ + {Key: "commodity.food", BasePrice: 12, IndustrialBias: -0.5, WealthBias: 0}, + {Key: "commodity.textiles", BasePrice: 18, IndustrialBias: -0.3, WealthBias: -0.1}, + {Key: "commodity.machinery", BasePrice: 65, IndustrialBias: 0.6, WealthBias: 0.1}, + {Key: "commodity.computers", BasePrice: 140, IndustrialBias: 0.5, WealthBias: 0.3}, + {Key: "commodity.luxuries", BasePrice: 220, IndustrialBias: 0, WealthBias: -0.6}, + {Key: "commodity.medicine", BasePrice: 90, IndustrialBias: 0.2, WealthBias: 0.2}, + {Key: "commodity.minerals", BasePrice: 35, IndustrialBias: 0.4, WealthBias: 0}, + {Key: "commodity.rare_metals", BasePrice: 310, IndustrialBias: 0.3, WealthBias: 0.4}, + {Key: "commodity.contraband", BasePrice: 260, IndustrialBias: 0, WealthBias: -0.4, Illegal: true}, + {Key: "commodity.firearms", BasePrice: 180, IndustrialBias: 0.3, WealthBias: 0, Illegal: true}, +} + +// wayfarerPriceNoise — насколько случайный шум может отклонить цену +// от расчётного значения (±15%). +const wayfarerPriceNoise = 0.15 + +// CommodityPrice вычисляет цену покупки товара commodityIdx в +// системе sys. rng используется для небольшого случайного разброса +// цены (тесты передают детерминированный источник). +func CommodityPrice(commodityIdx int, sys WayfarerSystem, rng *rand.Rand) float64 { + c := WayfarerCommodities[commodityIdx] + price := c.BasePrice + price *= 1 - c.IndustrialBias*(sys.Industrial*2-1)*0.5 + price *= 1 - c.WealthBias*(sys.Wealth*2-1)*0.5 + + noise := 1 + (rng.Float64()*2-1)*wayfarerPriceNoise + price *= noise + if price < 1 { + price = 1 + } + return price +} + +// SellPrice — цена продажи (обычно чуть ниже цены покупки — так +// станция зарабатывает на посреднической марже, как в привычных +// торговых играх). +func SellPrice(buyPrice float64) float64 { + return buyPrice * 0.9 +} diff --git a/wayfarer_economy_test.go b/wayfarer_economy_test.go new file mode 100644 index 0000000..4b8d85c --- /dev/null +++ b/wayfarer_economy_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "math/rand" + "testing" +) + +func TestMachineryCheaperInIndustrialSystems(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + industrial := WayfarerSystem{Industrial: 1, Wealth: 0.5} + agricultural := WayfarerSystem{Industrial: 0, Wealth: 0.5} + + machineryIdx := commodityIndexByKey(t, "commodity.machinery") + industrialPrice := CommodityPrice(machineryIdx, industrial, rng) + agriculturalPrice := CommodityPrice(machineryIdx, agricultural, rng) + if industrialPrice >= agriculturalPrice { + t.Errorf("машины должны быть дешевле в промышленной системе: industrial=%v agricultural=%v", + industrialPrice, agriculturalPrice) + } +} + +func TestFoodCheaperInAgriculturalSystems(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + industrial := WayfarerSystem{Industrial: 1, Wealth: 0.5} + agricultural := WayfarerSystem{Industrial: 0, Wealth: 0.5} + + foodIdx := commodityIndexByKey(t, "commodity.food") + industrialPrice := CommodityPrice(foodIdx, industrial, rng) + agriculturalPrice := CommodityPrice(foodIdx, agricultural, rng) + if agriculturalPrice >= industrialPrice { + t.Errorf("еда должна быть дешевле в аграрной системе: agricultural=%v industrial=%v", + agriculturalPrice, industrialPrice) + } +} + +func TestLuxuriesCheaperInPoorSystems(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + rich := WayfarerSystem{Industrial: 0.5, Wealth: 1} + poor := WayfarerSystem{Industrial: 0.5, Wealth: 0} + + luxuryIdx := commodityIndexByKey(t, "commodity.luxuries") + richPrice := CommodityPrice(luxuryIdx, rich, rng) + poorPrice := CommodityPrice(luxuryIdx, poor, rng) + if poorPrice >= richPrice { + t.Errorf("предметы роскоши должны быть дешевле в бедной системе при таком знаке WealthBias: poor=%v rich=%v", + poorPrice, richPrice) + } +} + +func TestSellPriceLowerThanBuyPrice(t *testing.T) { + buy := 100.0 + sell := SellPrice(buy) + if sell >= buy { + t.Errorf("цена продажи должна быть ниже цены покупки, получено sell=%v buy=%v", sell, buy) + } +} + +func TestCommodityPriceNeverNegativeOrZero(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + extreme := WayfarerSystem{Industrial: 1, Wealth: 1} + for i := range WayfarerCommodities { + for trial := 0; trial < 20; trial++ { + p := CommodityPrice(i, extreme, rng) + if p <= 0 { + t.Fatalf("цена товара %d не должна быть <= 0, получено %v", i, p) + } + } + } +} + +func commodityIndexByKey(t *testing.T, key string) int { + t.Helper() + for i, c := range WayfarerCommodities { + if c.Key == key { + return i + } + } + t.Fatalf("товар с ключом %q не найден", key) + return -1 +} diff --git a/wayfarer_encounters.go b/wayfarer_encounters.go new file mode 100644 index 0000000..f958465 --- /dev/null +++ b/wayfarer_encounters.go @@ -0,0 +1,148 @@ +package main + +import "math/rand" + +// WayfarerEncounterType — вид случайной встречи во время перелёта. +type WayfarerEncounterType int + +const ( + EncounterNone WayfarerEncounterType = iota + EncounterPirate + EncounterPoliceScan + EncounterDistress + EncounterDerelict +) + +// wayfarerBaseEncounterChance — базовая вероятность хоть какой-то +// встречи за один прыжок при "нейтральном" (danger factor = 1) +// уровне опасности системы назначения. +const wayfarerBaseEncounterChance = 0.18 + +// RollEncounter решает, произойдёт ли что-то во время перелёта в +// систему dest, и если да — что именно. +func RollEncounter(dest WayfarerSystem, rnd *rand.Rand) WayfarerEncounterType { + chance := wayfarerBaseEncounterChance * dest.DangerFactor() + if chance > 0.9 { + chance = 0.9 + } + if rnd.Float64() > chance { + return EncounterNone + } + roll := rnd.Float64() + switch { + case roll < 0.45: + return EncounterPirate + case roll < 0.65: + return EncounterPoliceScan + case roll < 0.85: + return EncounterDistress + default: + return EncounterDerelict + } +} + +// GeneratePirateStats создаёт статы вражеского корабля, +// отмасштабированные под опасность системы dest — в более опасных +// (например, анархических) системах пираты в среднем крепче и злее. +func GeneratePirateStats(dest WayfarerSystem, rnd *rand.Rand) (hull, laser int) { + baseHull := 15 + rnd.Intn(20) + baseLaser := 4 + rnd.Intn(6) + hull = int(float64(baseHull) * dest.DangerFactor()) + laser = int(float64(baseLaser) * dest.DangerFactor()) + if hull < 5 { + hull = 5 + } + if laser < 1 { + laser = 1 + } + return hull, laser +} + +// HasIllegalCargo проверяет, везёт ли корабль хоть что-то из +// незаконных товаров (WayfarerCommodities[i].Illegal). +func (s *WayfarerShip) HasIllegalCargo() bool { + for i, c := range WayfarerCommodities { + if c.Illegal && s.Cargo[i] > 0 { + return true + } + } + return false +} + +// ConfiscateIllegalCargo изымает весь незаконный груз и возвращает +// его условную стоимость (для отображения игроку, что он потерял). +func (s *WayfarerShip) ConfiscateIllegalCargo() float64 { + lost := 0.0 + for i, c := range WayfarerCommodities { + if c.Illegal && s.Cargo[i] > 0 { + lost += float64(s.Cargo[i]) * c.BasePrice + s.Cargo[i] = 0 + } + } + return lost +} + +// wayfarerDerelictMin/Max — диапазон случайной находки на обломках. +const ( + wayfarerDerelictMin = 50.0 + wayfarerDerelictMax = 250.0 +) + +// DerelictFind начисляет игроку случайную находку с обломков +// корабля и возвращает её размер. +func (s *WayfarerShip) DerelictFind(rnd *rand.Rand) float64 { + found := wayfarerDerelictMin + rnd.Float64()*(wayfarerDerelictMax-wayfarerDerelictMin) + s.Credits += found + return found +} + +// wayfarerQuestOfferChance — с такой вероятностью прибытие в +// систему (если сейчас нет активного задания и остались +// невыполненные/непровальные квесты) заканчивается предложением +// одного из них — вместо свободного выбора квеста из списка когда +// угодно, как раньше. +const wayfarerQuestOfferChance = 0.25 + +// RollQuestOffer решает, предлагается ли игроку квест по прибытии в +// систему. Возвращает (ключ квеста, true), если да. +func RollQuestOffer(ship *WayfarerShip, rnd *rand.Rand) (string, bool) { + if ship.ActiveQuest != nil { + return "", false + } + var candidates []string + for _, q := range WayfarerQuestDefs { + if !ship.CompletedQuests[q.Key] && !ship.FailedQuests[q.Key] { + candidates = append(candidates, q.Key) + } + } + if len(candidates) == 0 { + return "", false + } + if rnd.Float64() > wayfarerQuestOfferChance { + return "", false + } + return candidates[rnd.Intn(len(candidates))], true +} + +// wayfarerDistressTrapChance — с такой вероятностью сигнал бедствия +// на самом деле окажется засадой (тогда вызывающий код должен +// начать бой) вместо честной просьбы о помощи. +const wayfarerDistressTrapChance = 0.25 + +const ( + wayfarerDistressRewardMin = 100.0 + wayfarerDistressRewardMax = 400.0 +) + +// HelpDistressed разрешает решение "откликнуться на сигнал +// бедствия": либо это оказывается ловушка (wasTrap == true — бой +// должен запустить вызывающий код отдельно, начисление тут не +// происходит), либо честная награда за помощь. +func (s *WayfarerShip) HelpDistressed(rnd *rand.Rand) (reward float64, wasTrap bool) { + if rnd.Float64() < wayfarerDistressTrapChance { + return 0, true + } + reward = wayfarerDistressRewardMin + rnd.Float64()*(wayfarerDistressRewardMax-wayfarerDistressRewardMin) + s.Credits += reward + return reward, false +} diff --git a/wayfarer_encounters_test.go b/wayfarer_encounters_test.go new file mode 100644 index 0000000..96f1f96 --- /dev/null +++ b/wayfarer_encounters_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "math/rand" + "testing" +) + +func TestRollEncounterMoreLikelyInDangerousSystems(t *testing.T) { + safe := WayfarerSystem{Government: GovCorporateState} + dangerous := WayfarerSystem{Government: GovAnarchy} + + safeCount, dangerousCount := 0, 0 + for seed := int64(0); seed < 1000; seed++ { + if RollEncounter(safe, rand.New(rand.NewSource(seed))) != EncounterNone { + safeCount++ + } + if RollEncounter(dangerous, rand.New(rand.NewSource(seed))) != EncounterNone { + dangerousCount++ + } + } + if dangerousCount <= safeCount { + t.Errorf("в опасной системе встреч должно быть заметно больше: dangerous=%d safe=%d", dangerousCount, safeCount) + } +} + +func TestRollQuestOfferNoneWhenQuestActive(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest(WayfarerQuestDefs[0].Key) + + for seed := int64(0); seed < 200; seed++ { + if _, ok := RollQuestOffer(s, rand.New(rand.NewSource(seed))); ok { + t.Fatalf("не должно предлагаться заданий, пока уже есть активное") + } + } +} + +func TestRollQuestOfferNoneWhenAllCompleted(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + for _, q := range WayfarerQuestDefs { + s.CompletedQuests[q.Key] = true + } + + for seed := int64(0); seed < 200; seed++ { + if _, ok := RollQuestOffer(s, rand.New(rand.NewSource(seed))); ok { + t.Fatalf("не должно предлагаться заданий, если все уже выполнены") + } + } +} + +func TestRollQuestOfferSkipsCompletedAndFailed(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + for i, q := range WayfarerQuestDefs { + if i%2 == 0 { + s.CompletedQuests[q.Key] = true + } else { + s.FailedQuests[q.Key] = true + } + } + // оставляем ровно одно доступное задание + lastKey := WayfarerQuestDefs[len(WayfarerQuestDefs)-1].Key + delete(s.CompletedQuests, lastKey) + delete(s.FailedQuests, lastKey) + + found := false + for seed := int64(0); seed < 500 && !found; seed++ { + if key, ok := RollQuestOffer(s, rand.New(rand.NewSource(seed))); ok { + if key != lastKey { + t.Fatalf("предложено завершённое/провальное задание %q вместо единственного доступного %q", key, lastKey) + } + found = true + } + } + if !found { + t.Fatalf("за 500 попыток ни разу не предложилось единственное доступное задание") + } +} + +func TestRollQuestOfferHappensSometimesNotAlways(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + offered, skipped := 0, 0 + for seed := int64(0); seed < 500; seed++ { + if _, ok := RollQuestOffer(s, rand.New(rand.NewSource(seed))); ok { + offered++ + } else { + skipped++ + } + } + if offered == 0 { + t.Errorf("за 500 попыток ни разу не предложилось задание — вероятность должна быть больше нуля") + } + if skipped == 0 { + t.Errorf("за 500 попыток задание предлагалось абсолютно всегда — должны быть и тихие прилёты") + } +} + +func TestGeneratePirateStatsScaleWithDanger(t *testing.T) { + safe := WayfarerSystem{Government: GovCorporateState} + dangerous := WayfarerSystem{Government: GovAnarchy} + + safeSum, dangerousSum := 0, 0 + for seed := int64(0); seed < 100; seed++ { + h1, _ := GeneratePirateStats(safe, rand.New(rand.NewSource(seed))) + h2, _ := GeneratePirateStats(dangerous, rand.New(rand.NewSource(seed))) + safeSum += h1 + dangerousSum += h2 + } + if dangerousSum <= safeSum { + t.Errorf("пираты в опасных системах должны быть в среднем крепче: dangerous=%d safe=%d", dangerousSum, safeSum) + } +} + +func TestHasIllegalCargoDetection(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + if s.HasIllegalCargo() { + t.Fatalf("новый корабль не должен везти незаконный груз") + } + idx := commodityIndexByKey(t, "commodity.contraband") + s.Cargo[idx] = 3 + if !s.HasIllegalCargo() { + t.Errorf("контрабанда в трюме должна была определиться как незаконный груз") + } +} + +func TestConfiscateIllegalCargoRemovesItAndReturnsValue(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + idx := commodityIndexByKey(t, "commodity.contraband") + s.Cargo[idx] = 4 + lost := s.ConfiscateIllegalCargo() + if s.Cargo[idx] != 0 { + t.Errorf("незаконный груз должен был полностью изыматься") + } + if lost <= 0 { + t.Errorf("должна была вернуться положительная оценка изъятого, получено %v", lost) + } + legalIdx := commodityIndexByKey(t, "commodity.food") + s.Cargo[legalIdx] = 10 + s.ConfiscateIllegalCargo() + if s.Cargo[legalIdx] != 10 { + t.Errorf("законный груз не должен был пострадать при конфискации") + } +} + +func TestDerelictFindAddsCredits(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + before := s.Credits + rnd := rand.New(rand.NewSource(1)) + found := s.DerelictFind(rnd) + if found <= 0 { + t.Errorf("находка должна быть положительной, получено %v", found) + } + if s.Credits != before+found { + t.Errorf("кредиты должны были вырасти ровно на размер находки") + } +} + +func TestHelpDistressedEventuallyTrapsAndRewards(t *testing.T) { + trapSeen, rewardSeen := false, false + for seed := int64(0); seed < 200 && !(trapSeen && rewardSeen); seed++ { + s := NewWayfarerShip(WayfarerSystemID{}) + before := s.Credits + reward, wasTrap := s.HelpDistressed(rand.New(rand.NewSource(seed))) + if wasTrap { + trapSeen = true + if s.Credits != before { + t.Errorf("ловушка не должна была начислять кредиты") + } + } else { + rewardSeen = true + if reward <= 0 || s.Credits != before+reward { + t.Errorf("честная помощь должна была начислить положительную награду") + } + } + } + if !trapSeen || !rewardSeen { + t.Errorf("ожидались оба исхода помощи бедствующим: ловушка=%v награда=%v", trapSeen, rewardSeen) + } +} diff --git a/wayfarer_env.go b/wayfarer_env.go new file mode 100644 index 0000000..fd17b01 --- /dev/null +++ b/wayfarer_env.go @@ -0,0 +1,76 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" +) + +// wayfarerEnvVar — имя переменной в .env, которой можно поменять +// сид генерации всей галактики Странника (пасхалка в духе +// оригинальной Elite, где числа генератора можно было поменять +// прямо в исходном коде игры — здесь то же самое, но без правки +// кода коллекции). +const wayfarerEnvVar = "WAYFARER_GALAXY_SEED" + +// loadWayfarerGalaxySeed ищет файл .env сначала рядом с исполняемым +// файлом программы, затем (если там не нашёлся или не удалось +// определить путь к исполняемому файлу — так бывает, например, при +// запуске через "go run .") в текущей рабочей директории. Если ни +// там, ни там подходящей строки нет — сид остаётся нулевым +// (стандартная галактика, поведение не меняется). +func loadWayfarerGalaxySeed() { + for _, dir := range wayfarerEnvSearchDirs() { + if seed, ok := readWayfarerSeedFromEnvFile(filepath.Join(dir, ".env")); ok { + wayfarerGalaxySeed = seed + return + } + } +} + +func wayfarerEnvSearchDirs() []string { + var dirs []string + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + if cwd, err := os.Getwd(); err == nil { + dirs = append(dirs, cwd) + } + return dirs +} + +// readWayfarerSeedFromEnvFile читает path и ищет в нём строку вида +// WAYFARER_GALAXY_SEED=<число> (пробелы вокруг "=" и в начале/конце +// значения игнорируются; строки-комментарии, начинающиеся с "#", и +// пустые строки пропускаются). Возвращает (0, false), если файла нет, +// его не удалось прочитать, либо в нём нет такой переменной или её +// значение не является целым числом. +func readWayfarerSeedFromEnvFile(path string) (uint64, bool) { + f, err := os.Open(path) + if err != nil { + return 0, false + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, "=") + if !found || strings.TrimSpace(key) != wayfarerEnvVar { + continue + } + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, false + } + return uint64(n), true + } + return 0, false +} diff --git a/wayfarer_env_test.go b/wayfarer_env_test.go new file mode 100644 index 0000000..67a47ad --- /dev/null +++ b/wayfarer_env_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// withWayfarerGalaxySeed временно подставляет seed и гарантированно +// возвращает прежнее значение после теста — иначе один тест мог бы +// незаметно повлиять на данные систем в других тестах пакета. +func withWayfarerGalaxySeed(t *testing.T, seed uint64) { + t.Helper() + old := wayfarerGalaxySeed + wayfarerGalaxySeed = seed + t.Cleanup(func() { wayfarerGalaxySeed = old }) +} + +func TestDefaultGalaxySeedMatchesUnmodifiedHash(t *testing.T) { + withWayfarerGalaxySeed(t, 0) + id := WayfarerSystemID{Galaxy: 2, Index: 17} + got := systemSeed(id) + + x := uint64(id.Galaxy)*1000003 + uint64(id.Index) + 0x9E3779B97F4A7C15 + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9 + x = (x ^ (x >> 27)) * 0x94D049BB133111EB + x = x ^ (x >> 31) + want := int64(x) + + if got != want { + t.Errorf("нулевой сид должен давать точно такой же хэш, как без учёта сида вовсе: got=%d want=%d", got, want) + } +} + +func TestDifferentGalaxySeedProducesDifferentSystem(t *testing.T) { + id := WayfarerSystemID{Galaxy: 0, Index: 0} + + withWayfarerGalaxySeed(t, 0) + base := GenerateSystem(id) + + withWayfarerGalaxySeed(t, 424242) + changed := GenerateSystem(id) + + if base.Name == changed.Name && base.X == changed.X && base.Y == changed.Y && + base.Government == changed.Government && base.TechLevel == changed.TechLevel { + t.Errorf("другой сид галактики должен был дать другую систему для того же ID") + } +} + +func TestGalaxySeedStillDeterministicWithinItself(t *testing.T) { + withWayfarerGalaxySeed(t, 555) + id := WayfarerSystemID{Galaxy: 3, Index: 100} + a := GenerateSystem(id) + b := GenerateSystem(id) + if a != b { + t.Errorf("при одном и том же (сид + ID) система должна оставаться детерминированной") + } +} + +func TestReadWayfarerSeedFromEnvFileValid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("SOME_OTHER_VAR=1\nWAYFARER_GALAXY_SEED=777\n"), 0o644) + + seed, ok := readWayfarerSeedFromEnvFile(path) + if !ok || seed != 777 { + t.Fatalf("ожидался сид 777, получено %d (ok=%v)", seed, ok) + } +} + +func TestReadWayfarerSeedFromEnvFileMissingFile(t *testing.T) { + _, ok := readWayfarerSeedFromEnvFile(filepath.Join(t.TempDir(), "nope.env")) + if ok { + t.Errorf("отсутствующий файл должен давать ok=false") + } +} + +func TestReadWayfarerSeedFromEnvFileNoMatchingKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("FOO=1\nBAR=2\n"), 0o644) + + _, ok := readWayfarerSeedFromEnvFile(path) + if ok { + t.Errorf("файл без нужного ключа должен давать ok=false") + } +} + +func TestReadWayfarerSeedFromEnvFileIgnoresCommentsAndBlankLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("# комментарий\n\n \nWAYFARER_GALAXY_SEED=99\n"), 0o644) + + seed, ok := readWayfarerSeedFromEnvFile(path) + if !ok || seed != 99 { + t.Fatalf("ожидался сид 99, получено %d (ok=%v)", seed, ok) + } +} + +func TestReadWayfarerSeedFromEnvFileHandlesQuotesAndSpaces(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte(`WAYFARER_GALAXY_SEED = "12345"`+"\n"), 0o644) + + seed, ok := readWayfarerSeedFromEnvFile(path) + if !ok || seed != 12345 { + t.Fatalf("ожидался сид 12345 с учётом пробелов/кавычек, получено %d (ok=%v)", seed, ok) + } +} + +func TestReadWayfarerSeedFromEnvFileNegativeNumber(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("WAYFARER_GALAXY_SEED=-5\n"), 0o644) + + _, ok := readWayfarerSeedFromEnvFile(path) + if !ok { + t.Errorf("отрицательное число должно успешно парситься (и просто давать другой битовый узор сида)") + } +} + +func TestReadWayfarerSeedFromEnvFileMalformedValue(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + os.WriteFile(path, []byte("WAYFARER_GALAXY_SEED=not-a-number\n"), 0o644) + + _, ok := readWayfarerSeedFromEnvFile(path) + if ok { + t.Errorf("нечисловое значение должно давать ok=false") + } +} + +func TestLoadWayfarerGalaxySeedNoFileLeavesDefault(t *testing.T) { + dir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldWd) + + defer func() { wayfarerGalaxySeed = 0 }() + wayfarerGalaxySeed = 0 + loadWayfarerGalaxySeed() + if wayfarerGalaxySeed != 0 { + t.Errorf("без файла .env сид должен остаться нулевым, получено %d", wayfarerGalaxySeed) + } +} + +func TestLoadWayfarerGalaxySeedFromCWD(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".env"), []byte("WAYFARER_GALAXY_SEED=42\n"), 0o644) + oldWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldWd) + + defer func() { wayfarerGalaxySeed = 0 }() + wayfarerGalaxySeed = 0 + loadWayfarerGalaxySeed() + if wayfarerGalaxySeed != 42 { + t.Errorf("ожидался сид 42, загруженный из .env в текущей директории, получено %d", wayfarerGalaxySeed) + } +} diff --git a/wayfarer_galaxy.go b/wayfarer_galaxy.go new file mode 100644 index 0000000..fe932f0 --- /dev/null +++ b/wayfarer_galaxy.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "math" + "math/rand" +) + +// ПРИМЕЧАНИЕ: "Странник" — текстовая космоторговля, вдохновлённая +// классической Elite (процедурная галактика, торговля, апгрейды +// корабля, риск в пути), но НЕ является её копией: собственный +// алгоритм генерации галактики (не побайтовое воспроизведение +// оригинального 6502-алгоритма), собственные названия кораблей, +// рас и систем, свой список квестов. Настоящего 3D-полёта в +// реальном времени нет — это сознательное решение: честная 3D- +// графика в терминале через Bubble Tea потребовала бы отдельного +// рендер-движка сопоставимой с самой игрой сложности. Вместо этого +// перелёты и опасные встречи разрешаются текстом с выбором. + +// WayfarerSystemID — идентификатор звёздной системы: галактика + +// порядковый номер внутри неё. Все свойства системы выводятся из +// этого идентификатора детерминированно (как исходная генерация +// Elite) — хранить нужно только идентификатор, не сами данные. +type WayfarerSystemID struct { + Galaxy int + Index int +} + +// MarshalText/UnmarshalText — encoding/json не умеет сериализовать +// map с ключом-структурой напрямую; эти методы позволяют +// WayfarerSystemID быть ключом map[WayfarerSystemID]bool (поле +// Visited) при сохранении карьеры в JSON. +func (id WayfarerSystemID) MarshalText() ([]byte, error) { + return []byte(fmt.Sprintf("%d:%d", id.Galaxy, id.Index)), nil +} + +func (id *WayfarerSystemID) UnmarshalText(text []byte) error { + _, err := fmt.Sscanf(string(text), "%d:%d", &id.Galaxy, &id.Index) + return err +} + +const ( + WayfarerGalaxyCount = 4 + WayfarerSystemsPerGalaxy = 128 + wayfarerGalaxyMapSize = 500.0 // условные световые годы на сторону карты галактики +) + +// WayfarerGovernment — тип правительства системы: влияет на +// опасность перелётов (шанс встретить пиратов) и на легальность +// некоторых товаров. +type WayfarerGovernment int + +const ( + GovAnarchy WayfarerGovernment = iota + GovFeudal + GovDictatorship + GovCommunist + GovConfederacy + GovDemocracy + GovCorporateState + wayfarerGovernmentCount +) + +// wayfarerGovernmentDangerFactor — во сколько раз обычная опасность +// пути умножается для системы с этим правительством (анархия +// заметно опаснее корпоративного государства). +var wayfarerGovernmentDangerFactor = map[WayfarerGovernment]float64{ + GovAnarchy: 2.2, + GovFeudal: 1.5, + GovDictatorship: 1.3, + GovCommunist: 1.1, + GovConfederacy: 0.9, + GovDemocracy: 0.8, + GovCorporateState: 0.6, +} + +// WayfarerSystem — производные (детерминированно вычисленные) +// данные одной звёздной системы. +type WayfarerSystem struct { + ID WayfarerSystemID + Name string + Government WayfarerGovernment + TechLevel int // 0-15 + Industrial float64 // 0 (чисто аграрная) .. 1 (чисто промышленная) + Wealth float64 // 0 (бедная) .. 1 (богатая) + Population float64 // в миллионах, условно + X, Y float64 // координаты на карте галактики (условные св. годы) +} + +// wayfarerGalaxySeed — необязательная "пасхалка": если рядом с +// исполняемым файлом (или в текущей рабочей директории) найден файл +// .env со строкой WAYFARER_GALAXY_SEED=<число>, вся галактика +// перегенерируется заново с другим содержимым (как изменение +// исходных чисел генератора в оригинальной Elite). По умолчанию 0 — +// это ничего не меняет в хэше (см. systemSeed) и даёт ту же самую +// галактику, что и без файла .env вовсе. +var wayfarerGalaxySeed uint64 + +// systemSeed детерминированно превращает идентификатор системы в +// 64-битное зерно генератора случайности — одна и та же система при +// одном и том же wayfarerGalaxySeed всегда даёт одни и те же данные. +func systemSeed(id WayfarerSystemID) int64 { + // простой, но достаточно хорошо перемешивающий детерминированный + // хэш (вариант splitmix64) — не претендует на криптостойкость, + // только на визуально несвязанный разброс результатов + x := uint64(id.Galaxy)*1000003 + uint64(id.Index) + 0x9E3779B97F4A7C15 + wayfarerGalaxySeed + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9 + x = (x ^ (x >> 27)) * 0x94D049BB133111EB + x = x ^ (x >> 31) + return int64(x) +} + +var wayfarerNameSyllables = []string{ + "di", "so", "lav", "en", "ra", "or", "bi", "lei", "ce", "ta", + "zon", "quen", "ve", "usu", "phi", "gal", "ari", "nex", "tor", "eth", + "in", "us", "on", "ax", "yr", "ul", "im", "os", "an", "el", +} + +// generateSystemName собирает произносимое "инопланетное" название +// из 2-3 слогов на основе персонального генератора системы. +func generateSystemName(rng *rand.Rand) string { + syllables := 2 + rng.Intn(2) + name := "" + for i := 0; i < syllables; i++ { + name += wayfarerNameSyllables[rng.Intn(len(wayfarerNameSyllables))] + } + return capitalizeFirst(name) +} + +func capitalizeFirst(s string) string { + if s == "" { + return s + } + r := []rune(s) + if r[0] >= 'a' && r[0] <= 'z' { + r[0] = r[0] - 'a' + 'A' + } + return string(r) +} + +// GenerateSystem детерминированно вычисляет все данные системы по +// её идентификатору. +func GenerateSystem(id WayfarerSystemID) WayfarerSystem { + rng := rand.New(rand.NewSource(systemSeed(id))) + return WayfarerSystem{ + ID: id, + Name: generateSystemName(rng), + Government: WayfarerGovernment(rng.Intn(int(wayfarerGovernmentCount))), + TechLevel: rng.Intn(16), + Industrial: rng.Float64(), + Wealth: rng.Float64(), + Population: math.Round(rng.Float64()*450*10) / 10, + X: rng.Float64() * wayfarerGalaxyMapSize, + Y: rng.Float64() * wayfarerGalaxyMapSize, + } +} + +// Distance — расстояние между двумя системами в условных световых +// годах (координаты считаются в пределах одной и той же галактики; +// межгалактический прыжок обрабатывается отдельно). +func (s WayfarerSystem) Distance(other WayfarerSystem) float64 { + dx := s.X - other.X + dy := s.Y - other.Y + return math.Sqrt(dx*dx + dy*dy) +} + +// DangerFactor — во сколько раз обычная базовая опасность перелёта +// из/в эту систему умножается из-за её правительства. +func (s WayfarerSystem) DangerFactor() float64 { + return wayfarerGovernmentDangerFactor[s.Government] +} + +func (s WayfarerSystem) String() string { + return fmt.Sprintf("%s (G%d/%d)", s.Name, s.ID.Galaxy, s.ID.Index) +} diff --git a/wayfarer_galaxy_test.go b/wayfarer_galaxy_test.go new file mode 100644 index 0000000..e9e10a2 --- /dev/null +++ b/wayfarer_galaxy_test.go @@ -0,0 +1,76 @@ +package main + +import "testing" + +func TestGenerateSystemIsDeterministic(t *testing.T) { + id := WayfarerSystemID{Galaxy: 1, Index: 42} + a := GenerateSystem(id) + b := GenerateSystem(id) + if a != b { + t.Fatalf("одна и та же система должна давать одинаковый результат: %+v vs %+v", a, b) + } +} + +func TestGenerateSystemDiffersByID(t *testing.T) { + a := GenerateSystem(WayfarerSystemID{Galaxy: 0, Index: 0}) + b := GenerateSystem(WayfarerSystemID{Galaxy: 0, Index: 1}) + if a.Name == b.Name && a.X == b.X && a.Y == b.Y { + t.Errorf("разные системы не должны совпадать целиком по всем параметрам") + } +} + +func TestGenerateSystemValueRanges(t *testing.T) { + for g := 0; g < WayfarerGalaxyCount; g++ { + for i := 0; i < WayfarerSystemsPerGalaxy; i++ { + s := GenerateSystem(WayfarerSystemID{Galaxy: g, Index: i}) + if s.TechLevel < 0 || s.TechLevel > 15 { + t.Fatalf("система %v: техуровень вне диапазона: %d", s.ID, s.TechLevel) + } + if s.Industrial < 0 || s.Industrial > 1 { + t.Fatalf("система %v: индустриальность вне диапазона: %v", s.ID, s.Industrial) + } + if s.Wealth < 0 || s.Wealth > 1 { + t.Fatalf("система %v: богатство вне диапазона: %v", s.ID, s.Wealth) + } + if s.Government < 0 || int(s.Government) >= int(wayfarerGovernmentCount) { + t.Fatalf("система %v: неверное правительство: %v", s.ID, s.Government) + } + if s.X < 0 || s.X > wayfarerGalaxyMapSize || s.Y < 0 || s.Y > wayfarerGalaxyMapSize { + t.Fatalf("система %v: координаты вне карты галактики: (%v,%v)", s.ID, s.X, s.Y) + } + if s.Name == "" { + t.Fatalf("система %v: пустое название", s.ID) + } + } + } +} + +func TestGenerateSystemNameVarietyIsReasonable(t *testing.T) { + names := map[string]bool{} + for i := 0; i < 200; i++ { + s := GenerateSystem(WayfarerSystemID{Galaxy: 0, Index: i}) + names[s.Name] = true + } + if len(names) < 100 { + t.Errorf("ожидалось разумное разнообразие названий среди 200 систем, получено только %d уникальных", len(names)) + } +} + +func TestDistanceSymmetric(t *testing.T) { + a := GenerateSystem(WayfarerSystemID{Galaxy: 0, Index: 1}) + b := GenerateSystem(WayfarerSystemID{Galaxy: 0, Index: 2}) + if a.Distance(b) != b.Distance(a) { + t.Errorf("расстояние должно быть симметричным") + } + if a.Distance(a) != 0 { + t.Errorf("расстояние системы до самой себя должно быть 0") + } +} + +func TestDangerFactorMatchesGovernment(t *testing.T) { + anarchy := WayfarerSystem{Government: GovAnarchy} + corporate := WayfarerSystem{Government: GovCorporateState} + if anarchy.DangerFactor() <= corporate.DangerFactor() { + t.Errorf("анархия должна быть опаснее корпоративного государства") + } +} diff --git a/wayfarer_quest_state.go b/wayfarer_quest_state.go new file mode 100644 index 0000000..17671f1 --- /dev/null +++ b/wayfarer_quest_state.go @@ -0,0 +1,150 @@ +package main + +// WayfarerQuestState — прогресс игрока в активном квесте (какой +// квест, на каком шаге). Сами тексты и ветвления хранятся отдельно, +// в WayfarerQuestDefs — так, чтобы состояние партии было маленьким и +// не дублировало неизменный контент квеста. +type WayfarerQuestState struct { + Key string + Step int +} + +// WayfarerQuestChoice — один вариант выбора на шаге квеста. +type WayfarerQuestChoice struct { + LabelKey string // ключ перевода текста варианта выбора + + // NextStep — индекс следующего шага квеста, если выбор не + // завершает квест. Игнорируется, если Terminal == true. + NextStep int + + // Terminal — завершает ли этот выбор квест немедленно. + Terminal bool + Success bool // при Terminal: считается ли это успехом или провалом квеста + + ResultKey string // ключ перевода текста-результата этого выбора + + CreditsDelta float64 // изменение кредитов (может быть отрицательным) + HullDelta int // изменение прочности корпуса (может быть отрицательным) + CargoIdx int // индекс товара в WayfarerCommodities, которого касается CargoDelta (-1, если не используется) + CargoDelta int // изменение количества этого товара в трюме + KillsDelta int // изменение числа побед (для пилотского рейтинга) +} + +// WayfarerQuestStep — один узел ветвления квеста: текст ситуации и +// доступные варианты выбора. +type WayfarerQuestStep struct { + TextKey string + Choices []WayfarerQuestChoice +} + +// WayfarerQuestDef — полное описание одного квеста: заголовок и все +// его шаги (шаг 0 — стартовый). +type WayfarerQuestDef struct { + Key string + TitleKey string + BriefKey string // короткое описание для списка доступных квестов + Steps []WayfarerQuestStep +} + +// FindWayfarerQuest ищет определение квеста по ключу среди +// WayfarerQuestDefs. +func FindWayfarerQuest(key string) (WayfarerQuestDef, bool) { + for _, q := range WayfarerQuestDefs { + if q.Key == key { + return q, true + } + } + return WayfarerQuestDef{}, false +} + +// StartQuest запускает квест key с самого начала (шаг 0), если он +// ещё не завершён и не провален ранее и сейчас нет другого +// активного квеста. +func (s *WayfarerShip) StartQuest(key string) error { + if s.ActiveQuest != nil { + return errWayfarerQuestAlreadyActive + } + if s.CompletedQuests[key] || s.FailedQuests[key] { + return errWayfarerQuestAlreadyDone + } + if _, ok := FindWayfarerQuest(key); !ok { + return errWayfarerQuestNotFound + } + s.ActiveQuest = &WayfarerQuestState{Key: key, Step: 0} + return nil +} + +// CurrentQuestStep возвращает текущий шаг активного квеста (или +// (_, false), если квеста нет). +func (s *WayfarerShip) CurrentQuestStep() (WayfarerQuestStep, bool) { + if s.ActiveQuest == nil { + return WayfarerQuestStep{}, false + } + def, ok := FindWayfarerQuest(s.ActiveQuest.Key) + if !ok || s.ActiveQuest.Step >= len(def.Steps) { + return WayfarerQuestStep{}, false + } + return def.Steps[s.ActiveQuest.Step], true +} + +// ChooseQuestOption применяет выбранный вариант choiceIdx текущего +// шага активного квеста: списывает/начисляет ресурсы и либо +// переходит на следующий шаг, либо завершает квест (успехом или +// провалом). +func (s *WayfarerShip) ChooseQuestOption(choiceIdx int) (WayfarerQuestChoice, error) { + step, ok := s.CurrentQuestStep() + if !ok { + return WayfarerQuestChoice{}, errWayfarerNoActiveQuest + } + if choiceIdx < 0 || choiceIdx >= len(step.Choices) { + return WayfarerQuestChoice{}, errWayfarerInvalidChoice + } + choice := step.Choices[choiceIdx] + + s.Credits += choice.CreditsDelta + if s.Credits < 0 { + s.Credits = 0 + } + s.Hull += choice.HullDelta + if s.Hull < 0 { + s.Hull = 0 + } + if s.Hull > s.MaxHull { + s.Hull = s.MaxHull + } + if choice.CargoIdx >= 0 && choice.CargoIdx < len(s.Cargo) { + s.Cargo[choice.CargoIdx] += choice.CargoDelta + if s.Cargo[choice.CargoIdx] < 0 { + s.Cargo[choice.CargoIdx] = 0 + } + } + s.Kills += choice.KillsDelta + + key := s.ActiveQuest.Key + if choice.Terminal { + if choice.Success { + s.CompletedQuests[key] = true + } else { + s.FailedQuests[key] = true + } + s.ActiveQuest = nil + } else { + s.ActiveQuest.Step = choice.NextStep + } + return choice, nil +} + +// AbandonQuest прерывает текущий активный квест без последствий, +// как если бы он никогда не начинался (не считается ни успехом, ни +// провалом — его можно будет начать заново позже). +func (s *WayfarerShip) AbandonQuest() { + s.ActiveQuest = nil +} + +var ( + errWayfarerQuestAlreadyActive = wayfarerErr("сейчас уже выполняется другой квест") + errWayfarerQuestAlreadyDone = wayfarerErr("этот квест уже завершён или провален ранее") + errWayfarerQuestNotFound = wayfarerErr("квест с таким ключом не найден") + errWayfarerNoActiveQuest = wayfarerErr("сейчас нет активного квеста") + errWayfarerInvalidChoice = wayfarerErr("недопустимый номер варианта") +) diff --git a/wayfarer_quest_state_test.go b/wayfarer_quest_state_test.go new file mode 100644 index 0000000..7648c61 --- /dev/null +++ b/wayfarer_quest_state_test.go @@ -0,0 +1,182 @@ +package main + +import "testing" + +// withTestQuest временно подменяет WayfarerQuestDefs на один +// тестовый квест с двумя шагами и восстанавливает оригинал по +// завершении теста. +func withTestQuest(t *testing.T) { + t.Helper() + original := WayfarerQuestDefs + WayfarerQuestDefs = []WayfarerQuestDef{ + { + Key: "test.quest", + TitleKey: "test.title", + BriefKey: "test.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "test.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "test.choice.a", NextStep: 1}, + {LabelKey: "test.choice.b", Terminal: true, Success: false, CreditsDelta: -50}, + }, + }, + { + TextKey: "test.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "test.choice.c", Terminal: true, Success: true, CreditsDelta: 500, HullDelta: -10}, + }, + }, + }, + }, + } + t.Cleanup(func() { WayfarerQuestDefs = original }) +} + +func TestStartQuestSetsActiveState(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + if err := s.StartQuest("test.quest"); err != nil { + t.Fatalf("неожиданная ошибка старта квеста: %v", err) + } + if s.ActiveQuest == nil || s.ActiveQuest.Key != "test.quest" || s.ActiveQuest.Step != 0 { + t.Fatalf("ожидалось активное состояние квеста на шаге 0, получено: %+v", s.ActiveQuest) + } +} + +func TestCannotStartQuestWhileAnotherActive(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + if err := s.StartQuest("test.quest"); err != errWayfarerQuestAlreadyActive { + t.Errorf("ожидалась ошибка уже активного квеста, получено: %v", err) + } +} + +func TestCannotStartUnknownQuest(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + if err := s.StartQuest("no.such.quest"); err != errWayfarerQuestNotFound { + t.Errorf("ожидалась ошибка ненайденного квеста, получено: %v", err) + } +} + +func TestChooseQuestOptionAdvancesToNextStep(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + + choice, err := s.ChooseQuestOption(0) // "a" -> шаг 1 + if err != nil { + t.Fatalf("неожиданная ошибка выбора: %v", err) + } + if choice.LabelKey != "test.choice.a" { + t.Errorf("ожидался выбор 'a', получено %s", choice.LabelKey) + } + if s.ActiveQuest == nil || s.ActiveQuest.Step != 1 { + t.Fatalf("ожидался переход на шаг 1, получено: %+v", s.ActiveQuest) + } + + step, ok := s.CurrentQuestStep() + if !ok || step.TextKey != "test.step1" { + t.Fatalf("ожидался текст шага 1, получено: %+v", step) + } +} + +func TestChooseTerminalChoiceEndsQuestSuccessfully(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + s.ChooseQuestOption(0) // на шаг 1 + + before := s.Credits + beforeHull := s.Hull + if _, err := s.ChooseQuestOption(0); err != nil { // "c" -> успешное завершение + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.ActiveQuest != nil { + t.Errorf("активный квест должен был сброситься после завершения") + } + if !s.CompletedQuests["test.quest"] { + t.Errorf("квест должен был отметиться как завершённый успешно") + } + if s.Credits != before+500 { + t.Errorf("ожидалось начисление 500 кредитов, получено %v (было %v)", s.Credits, before) + } + if s.Hull != beforeHull-10 { + t.Errorf("ожидалось повреждение корпуса на 10, получено %d (было %d)", s.Hull, beforeHull) + } +} + +func TestChooseTerminalChoiceCanEndQuestInFailure(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + + before := s.Credits + if _, err := s.ChooseQuestOption(1); err != nil { // "b" -> немедленный провал + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.ActiveQuest != nil { + t.Errorf("активный квест должен был сброситься после провала") + } + if !s.FailedQuests["test.quest"] { + t.Errorf("квест должен был отметиться как провальный") + } + if s.Credits != before-50 { + t.Errorf("ожидалось списание 50 кредитов, получено %v (было %v)", s.Credits, before) + } +} + +func TestCannotRestartCompletedQuest(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + s.ChooseQuestOption(0) + s.ChooseQuestOption(0) + + if err := s.StartQuest("test.quest"); err != errWayfarerQuestAlreadyDone { + t.Errorf("ожидалась ошибка уже завершённого квеста, получено: %v", err) + } +} + +func TestChooseQuestOptionInvalidIndex(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + if _, err := s.ChooseQuestOption(99); err != errWayfarerInvalidChoice { + t.Errorf("ожидалась ошибка недопустимого выбора, получено: %v", err) + } +} + +func TestChooseQuestOptionWithNoActiveQuest(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + if _, err := s.ChooseQuestOption(0); err != errWayfarerNoActiveQuest { + t.Errorf("ожидалась ошибка отсутствия активного квеста, получено: %v", err) + } +} + +func TestAbandonQuestAllowsRestartLater(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.StartQuest("test.quest") + s.AbandonQuest() + if s.ActiveQuest != nil { + t.Errorf("активный квест должен был обнулиться после отказа") + } + if err := s.StartQuest("test.quest"); err != nil { + t.Errorf("после отказа квест должен быть доступен для повторного старта, получено: %v", err) + } +} + +func TestQuestCreditsAndCargoNeverGoNegative(t *testing.T) { + withTestQuest(t) + s := NewWayfarerShip(WayfarerSystemID{}) + s.Credits = 10 + s.StartQuest("test.quest") + s.ChooseQuestOption(1) // -50 кредитов при балансе 10 + if s.Credits < 0 { + t.Errorf("кредиты не должны уходить в минус, получено %v", s.Credits) + } +} diff --git a/wayfarer_quests.go b/wayfarer_quests.go new file mode 100644 index 0000000..b6a2e78 --- /dev/null +++ b/wayfarer_quests.go @@ -0,0 +1,339 @@ +package main + +// WayfarerQuestDefs — восемь проработанных сюжетных квестов, каждый +// со своими ветвлениями. Сами тексты — в translations_wayfarer.go +// (ключи вида "quest.<имя>.stepN" / "quest.<имя>.choiceN.M" и т.д.), +// здесь только структура: индексы шагов и последствия выборов. +var WayfarerQuestDefs = []WayfarerQuestDef{ + questSupernova, + questEscort, + questDerelict, + questSmuggler, + questContact, + questBounty, + questPlague, + questArtifact, +} + +// --- 1. Эвакуация перед вспышкой сверхновой ------------------------- + +var questSupernova = WayfarerQuestDef{ + Key: "quest.supernova", + TitleKey: "quest.supernova.title", + BriefKey: "quest.supernova.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.supernova.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.supernova.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.supernova.choice0.1", NextStep: 2, CargoIdx: -1}, + {LabelKey: "quest.supernova.choice0.2", Terminal: true, Success: false, ResultKey: "quest.supernova.result0.2", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.supernova.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.supernova.choice1.0", Terminal: true, Success: true, ResultKey: "quest.supernova.result1.0", + CreditsDelta: 900, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.supernova.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.supernova.choice2.0", NextStep: 3, CargoIdx: -1}, + {LabelKey: "quest.supernova.choice2.1", NextStep: 4, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.supernova.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.supernova.choice3.0", Terminal: true, Success: true, ResultKey: "quest.supernova.result3.0", + CreditsDelta: 1600, HullDelta: -35, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.supernova.step4", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.supernova.choice4.0", Terminal: true, Success: true, ResultKey: "quest.supernova.result4.0", + CreditsDelta: 400, CargoIdx: -1}, + }, + }, + }, +} + +// --- 2. Дипломатический эскорт -------------------------------------- + +var questEscort = WayfarerQuestDef{ + Key: "quest.escort", + TitleKey: "quest.escort.title", + BriefKey: "quest.escort.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.escort.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.escort.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.escort.choice0.1", Terminal: true, Success: false, ResultKey: "quest.escort.result0.1", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.escort.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.escort.choice1.0", NextStep: 2, HullDelta: -20, KillsDelta: 1, CargoIdx: -1}, + {LabelKey: "quest.escort.choice1.1", NextStep: 3, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.escort.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.escort.choice2.0", Terminal: true, Success: true, ResultKey: "quest.escort.result2.0", + CreditsDelta: 1100, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.escort.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.escort.choice3.0", Terminal: true, Success: true, ResultKey: "quest.escort.result3.0", + CreditsDelta: 700, CargoIdx: -1}, + }, + }, + }, +} + +// --- 3. Сигнал с обломков -------------------------------------------- + +var questDerelict = WayfarerQuestDef{ + Key: "quest.derelict", + TitleKey: "quest.derelict.title", + BriefKey: "quest.derelict.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.derelict.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.derelict.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.derelict.choice0.1", Terminal: true, Success: false, ResultKey: "quest.derelict.result0.1", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.derelict.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.derelict.choice1.0", NextStep: 2, CargoIdx: -1}, + {LabelKey: "quest.derelict.choice1.1", NextStep: 3, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.derelict.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.derelict.choice2.0", Terminal: true, Success: true, ResultKey: "quest.derelict.result2.0", + CreditsDelta: 1400, HullDelta: -10, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.derelict.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.derelict.choice3.0", Terminal: true, Success: true, ResultKey: "quest.derelict.result3.0", + CreditsDelta: 500, CargoIdx: -1}, + }, + }, + }, +} + +// --- 4. Груз для мятежников (моральный выбор) ------------------------ + +var questSmuggler = WayfarerQuestDef{ + Key: "quest.smuggler", + TitleKey: "quest.smuggler.title", + BriefKey: "quest.smuggler.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.smuggler.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.smuggler.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.smuggler.choice0.1", Terminal: true, Success: true, ResultKey: "quest.smuggler.result0.1", + CreditsDelta: 300, CargoIdx: -1}, + {LabelKey: "quest.smuggler.choice0.2", Terminal: true, Success: false, ResultKey: "quest.smuggler.result0.2", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.smuggler.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.smuggler.choice1.0", NextStep: 2, CargoIdx: -1}, + {LabelKey: "quest.smuggler.choice1.1", NextStep: 3, HullDelta: -15, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.smuggler.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.smuggler.choice2.0", Terminal: true, Success: true, ResultKey: "quest.smuggler.result2.0", + CreditsDelta: 1100, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.smuggler.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.smuggler.choice3.0", Terminal: true, Success: true, ResultKey: "quest.smuggler.result3.0", + CreditsDelta: 1500, KillsDelta: 1, CargoIdx: -1}, + }, + }, + }, +} + +// --- 5. Первый контакт ------------------------------------------------- + +var questContact = WayfarerQuestDef{ + Key: "quest.contact", + TitleKey: "quest.contact.title", + BriefKey: "quest.contact.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.contact.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.contact.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.contact.choice0.1", NextStep: 2, CargoIdx: -1}, + {LabelKey: "quest.contact.choice0.2", Terminal: true, Success: false, ResultKey: "quest.contact.result0.2", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.contact.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.contact.choice1.0", Terminal: true, Success: true, ResultKey: "quest.contact.result1.0", + CreditsDelta: 1300, CargoIdx: 0, CargoDelta: -3}, + }, + }, + { + TextKey: "quest.contact.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.contact.choice2.0", NextStep: 3, CargoIdx: -1}, + {LabelKey: "quest.contact.choice2.1", Terminal: true, Success: false, ResultKey: "quest.contact.result2.1", HullDelta: -25, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.contact.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.contact.choice3.0", Terminal: true, Success: true, ResultKey: "quest.contact.result3.0", + CreditsDelta: 800, CargoIdx: -1}, + }, + }, + }, +} + +// --- 6. Охота за головой ----------------------------------------------- + +var questBounty = WayfarerQuestDef{ + Key: "quest.bounty", + TitleKey: "quest.bounty.title", + BriefKey: "quest.bounty.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.bounty.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.bounty.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.bounty.choice0.1", Terminal: true, Success: false, ResultKey: "quest.bounty.result0.1", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.bounty.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.bounty.choice1.0", NextStep: 2, HullDelta: -30, KillsDelta: 1, CargoIdx: -1}, + {LabelKey: "quest.bounty.choice1.1", NextStep: 3, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.bounty.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.bounty.choice2.0", Terminal: true, Success: true, ResultKey: "quest.bounty.result2.0", + CreditsDelta: 2200, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.bounty.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.bounty.choice3.0", Terminal: true, Success: true, ResultKey: "quest.bounty.result3.0", + CreditsDelta: 900, CargoIdx: -1}, + }, + }, + }, +} + +// --- 7. Карантин (мор на шахтёрской станции) --------------------------- + +var questPlague = WayfarerQuestDef{ + Key: "quest.plague", + TitleKey: "quest.plague.title", + BriefKey: "quest.plague.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.plague.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.plague.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.plague.choice0.1", Terminal: true, Success: false, ResultKey: "quest.plague.result0.1", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.plague.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.plague.choice1.0", NextStep: 2, CargoIdx: 5, CargoDelta: -10}, + {LabelKey: "quest.plague.choice1.1", NextStep: 3, CargoIdx: 5, CargoDelta: -10}, + }, + }, + { + TextKey: "quest.plague.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.plague.choice2.0", Terminal: true, Success: true, ResultKey: "quest.plague.result2.0", + CreditsDelta: 300, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.plague.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.plague.choice3.0", Terminal: true, Success: true, ResultKey: "quest.plague.result3.0", + CreditsDelta: 2000, CargoIdx: -1}, + }, + }, + }, +} + +// --- 8. Находка предтеч -------------------------------------------------- + +var questArtifact = WayfarerQuestDef{ + Key: "quest.artifact", + TitleKey: "quest.artifact.title", + BriefKey: "quest.artifact.brief", + Steps: []WayfarerQuestStep{ + { + TextKey: "quest.artifact.step0", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.artifact.choice0.0", NextStep: 1, CargoIdx: -1}, + {LabelKey: "quest.artifact.choice0.1", Terminal: true, Success: false, ResultKey: "quest.artifact.result0.1", CargoIdx: -1}, + }, + }, + { + TextKey: "quest.artifact.step1", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.artifact.choice1.0", NextStep: 2, CargoIdx: -1}, + {LabelKey: "quest.artifact.choice1.1", NextStep: 3, CargoIdx: -1}, + {LabelKey: "quest.artifact.choice1.2", NextStep: 4, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.artifact.step2", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.artifact.choice2.0", Terminal: true, Success: true, ResultKey: "quest.artifact.result2.0", + CreditsDelta: 600, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.artifact.step3", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.artifact.choice3.0", Terminal: true, Success: true, ResultKey: "quest.artifact.result3.0", + CreditsDelta: 2500, CargoIdx: -1}, + }, + }, + { + TextKey: "quest.artifact.step4", + Choices: []WayfarerQuestChoice{ + {LabelKey: "quest.artifact.choice4.0", Terminal: true, Success: true, ResultKey: "quest.artifact.result4.0", + CreditsDelta: 1400, CargoIdx: -1}, + }, + }, + }, +} diff --git a/wayfarer_quests_test.go b/wayfarer_quests_test.go new file mode 100644 index 0000000..15487fa --- /dev/null +++ b/wayfarer_quests_test.go @@ -0,0 +1,126 @@ +package main + +import "testing" + +// TestWayfarerQuestDefsStructuralIntegrity проверяет каждый из +// реальных (не тестовых) квестов на внутреннюю согласованность: +// все NextStep указывают на существующие шаги, все CargoIdx либо -1 +// (не используется), либо валидный индекс в WayfarerCommodities, и +// у каждого квеста есть хотя бы один способ дойти до Terminal-выбора +// от стартового шага. +func TestWayfarerQuestDefsStructuralIntegrity(t *testing.T) { + if len(WayfarerQuestDefs) != 8 { + t.Fatalf("ожидалось 8 квестов, получено %d", len(WayfarerQuestDefs)) + } + + seenKeys := map[string]bool{} + for _, q := range WayfarerQuestDefs { + if q.Key == "" || q.TitleKey == "" || q.BriefKey == "" { + t.Errorf("квест с пустыми обязательными ключами: %+v", q) + } + if seenKeys[q.Key] { + t.Errorf("дублирующийся ключ квеста: %s", q.Key) + } + seenKeys[q.Key] = true + + if len(q.Steps) == 0 { + t.Errorf("квест %s: должен иметь хотя бы один шаг", q.Key) + } + + reachesTerminal := false + for stepIdx, step := range q.Steps { + if step.TextKey == "" { + t.Errorf("квест %s, шаг %d: пустой TextKey", q.Key, stepIdx) + } + if len(step.Choices) == 0 { + t.Errorf("квест %s, шаг %d: нет ни одного варианта выбора", q.Key, stepIdx) + } + for choiceIdx, c := range step.Choices { + if c.LabelKey == "" { + t.Errorf("квест %s, шаг %d, выбор %d: пустой LabelKey", q.Key, stepIdx, choiceIdx) + } + if c.CargoIdx != -1 && (c.CargoIdx < 0 || c.CargoIdx >= len(WayfarerCommodities)) { + t.Errorf("квест %s, шаг %d, выбор %d: недопустимый CargoIdx %d", q.Key, stepIdx, choiceIdx, c.CargoIdx) + } + if c.Terminal { + if c.ResultKey == "" { + t.Errorf("квест %s, шаг %d, выбор %d: terminal-выбор без ResultKey", q.Key, stepIdx, choiceIdx) + } + reachesTerminal = true + } else { + if c.NextStep < 0 || c.NextStep >= len(q.Steps) { + t.Errorf("квест %s, шаг %d, выбор %d: NextStep %d вне диапазона шагов (0..%d)", + q.Key, stepIdx, choiceIdx, c.NextStep, len(q.Steps)-1) + } + } + } + } + if !reachesTerminal { + t.Errorf("квест %s: ни один выбор во всём квесте не завершает его (нет ни одного Terminal)", q.Key) + } + } +} + +// TestWayfarerQuestDefsAllTranslated проверяет, что для каждого +// ключа перевода, используемого в квестах, есть запись в таблице +// translations на обоих языках (это ловит опечатки в ключах и +// забытые переводы). +func TestWayfarerQuestDefsAllTranslated(t *testing.T) { + checkKey := func(key string) { + t.Helper() + if key == "" { + return + } + entry, ok := translations[key] + if !ok { + t.Errorf("отсутствует перевод для ключа %q", key) + return + } + if entry.ru == "" || entry.en == "" { + t.Errorf("перевод для ключа %q неполный: ru=%q en=%q", key, entry.ru, entry.en) + } + } + + for _, q := range WayfarerQuestDefs { + checkKey(q.TitleKey) + checkKey(q.BriefKey) + for _, step := range q.Steps { + checkKey(step.TextKey) + for _, c := range step.Choices { + checkKey(c.LabelKey) + checkKey(c.ResultKey) + } + } + } +} + +// TestWayfarerQuestDefsPlayableToCompletion проверяет, что каждый +// квест реально проходим от начала до какого-либо Terminal-выбора, +// просто выбирая первый не-terminal вариант на каждом шаге (а если +// таких нет — берёт первый попавшийся) — ловит зависания/бесконечные +// циклы в ручной разметке шагов. +func TestWayfarerQuestDefsPlayableToCompletion(t *testing.T) { + for _, q := range WayfarerQuestDefs { + s := NewWayfarerShip(WayfarerSystemID{}) + if err := s.StartQuest(q.Key); err != nil { + t.Fatalf("квест %s: не удалось начать: %v", q.Key, err) + } + const maxSteps = 20 + for i := 0; i < maxSteps && s.ActiveQuest != nil; i++ { + step, _ := s.CurrentQuestStep() + choiceIdx := 0 + for idx, c := range step.Choices { + if !c.Terminal { + choiceIdx = idx + break + } + } + if _, err := s.ChooseQuestOption(choiceIdx); err != nil { + t.Fatalf("квест %s: неожиданная ошибка выбора на шаге: %v", q.Key, err) + } + } + if s.ActiveQuest != nil { + t.Errorf("квест %s: не завершился за %d шагов (возможен бесконечный цикл)", q.Key, maxSteps) + } + } +} diff --git a/wayfarer_save.go b/wayfarer_save.go new file mode 100644 index 0000000..8170598 --- /dev/null +++ b/wayfarer_save.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// ПРИМЕЧАНИЕ: как и "Трупные батончики", "Странник" сохраняется на +// диск — это открытая, долгоиграющая карьера без фиксированного +// конца партии, и жалко было бы сбрасывать прогресс при каждом +// выходе в меню. Остальные игры коллекции по-прежнему не сохраняются +// между запусками. + +var ErrWayfarerNoSave = errors.New("файл сохранения не найден") + +func wayfarerSavePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "go-games-collection", "wayfarer_save.json"), nil +} + +// SaveWayfarerShip сохраняет текущее состояние карьеры на диск. +func SaveWayfarerShip(s *WayfarerShip) error { + path, err := wayfarerSavePath() + 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) +} + +// LoadWayfarerShip загружает сохранённую карьеру с диска. Возвращает +// ErrWayfarerNoSave, если файла нет — вызывающий код должен в этом +// случае начать новую карьеру. +func LoadWayfarerShip() (*WayfarerShip, error) { + path, err := wayfarerSavePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrWayfarerNoSave + } + return nil, err + } + var s WayfarerShip + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + if s.Equipment == nil { + s.Equipment = map[WayfarerEquipment]bool{} + } + if s.Visited == nil { + s.Visited = map[WayfarerSystemID]bool{} + } + if s.CompletedQuests == nil { + s.CompletedQuests = map[string]bool{} + } + if s.FailedQuests == nil { + s.FailedQuests = map[string]bool{} + } + if len(s.Cargo) < len(WayfarerCommodities) { + grown := make([]int, len(WayfarerCommodities)) + copy(grown, s.Cargo) + s.Cargo = grown + } + return &s, nil +} + +// DeleteWayfarerSave удаляет файл сохранения (используется при +// гибели корабля без спасательной капсулы — карьера окончена +// безвозвратно, начинать заново нужно с чистого листа). +func DeleteWayfarerSave() error { + path, err := wayfarerSavePath() + if err != nil { + return err + } + err = os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/wayfarer_ship.go b/wayfarer_ship.go new file mode 100644 index 0000000..d250353 --- /dev/null +++ b/wayfarer_ship.go @@ -0,0 +1,244 @@ +package main + +import "math" + +// WayfarerEquipment — необязательное снаряжение корабля. +type WayfarerEquipment int + +const ( + EquipFuelScoop WayfarerEquipment = iota + EquipCargoExpansion + EquipShieldBooster + EquipEscapePod + EquipBetterLaser +) + +// WayfarerShip — полное состояние корабля и его владельца. +type WayfarerShip struct { + Credits float64 + + FuelCapacity float64 + Fuel float64 + FuelPerLY float64 // расход топлива на 1 условный световой год + + CargoCapacity int + Cargo []int // Cargo[i] — количество товара i в трюме + CargoCostBasis []float64 // CargoCostBasis[i] — средняя цена покупки за единицу текущего груза i (0, если груза нет) + + MaxHull int + Hull int + MaxShield int + Shield int + LaserPower int + + Equipment map[WayfarerEquipment]bool + + Kills int // сколько вражеских кораблей уничтожено — для рейтинга пилота + + CurrentSystem WayfarerSystemID + Visited map[WayfarerSystemID]bool + + ActiveQuest *WayfarerQuestState + CompletedQuests map[string]bool + FailedQuests map[string]bool +} + +// NewWayfarerShip создаёт начальный корабль игрока — скромный, но +// работоспособный торговец на старте карьеры. +func NewWayfarerShip(start WayfarerSystemID) *WayfarerShip { + s := &WayfarerShip{ + Credits: 1000, + FuelCapacity: 7, + Fuel: 7, + FuelPerLY: 1.0 / 30.0, // условно: полный бак = ~7 прыжков по системе средней удалённости + CargoCapacity: 20, + MaxHull: 100, + Hull: 100, + MaxShield: 50, + Shield: 50, + LaserPower: 12, + Equipment: map[WayfarerEquipment]bool{}, + CurrentSystem: start, + Visited: map[WayfarerSystemID]bool{start: true}, + CompletedQuests: map[string]bool{}, + FailedQuests: map[string]bool{}, + } + s.Cargo = make([]int, len(WayfarerCommodities)) + s.CargoCostBasis = make([]float64, len(WayfarerCommodities)) + return s +} + +// CargoUsed — сколько единиц трюма сейчас занято. +func (s *WayfarerShip) CargoUsed() int { + used := 0 + for _, n := range s.Cargo { + used += n + } + return used +} + +// CargoFree — сколько единиц трюма ещё свободно. +func (s *WayfarerShip) CargoFree() int { + return s.CargoCapacity - s.CargoUsed() +} + +// MaxJumpRange — максимальное расстояние прыжка при полном баке. +func (s *WayfarerShip) MaxJumpRange() float64 { + return s.FuelCapacity / s.FuelPerLY +} + +// FuelRange — максимальное расстояние прыжка при текущем запасе +// топлива. +func (s *WayfarerShip) FuelRange() float64 { + return s.Fuel / s.FuelPerLY +} + +var ( + errWayfarerInsufficientFunds = wayfarerErr("недостаточно кредитов") + errWayfarerCargoFull = wayfarerErr("трюм заполнен") + errWayfarerNoCargo = wayfarerErr("этого товара нет в трюме в таком количестве") + errWayfarerOutOfRange = wayfarerErr("не хватит топлива долететь так далеко") + errWayfarerSameSystem = wayfarerErr("вы уже в этой системе") +) + +type wayfarerErr string + +func (e wayfarerErr) Error() string { return string(e) } + +// BuyCommodity покупает qty единиц товара commodityIdx по цене +// unitPrice за единицу (цена вычисляется вызывающим кодом через +// CommodityPrice — так UI и движок используют одно и то же число, +// без риска рассинхронизации от повторного вызова генератора +// случайности). Заодно обновляет среднюю цену покупки этого товара +// в трюме (взвешенное среднее старой и новой партии) — чтобы потом +// на рынке можно было показать, по какой цене товар куплен. +func (s *WayfarerShip) BuyCommodity(commodityIdx int, qty int, unitPrice float64) error { + if qty <= 0 { + return nil + } + if qty > s.CargoFree() { + return errWayfarerCargoFull + } + cost := unitPrice * float64(qty) + if cost > s.Credits { + return errWayfarerInsufficientFunds + } + s.Credits -= cost + + oldQty := s.Cargo[commodityIdx] + oldTotal := s.CargoCostBasis[commodityIdx] * float64(oldQty) + s.Cargo[commodityIdx] += qty + s.CargoCostBasis[commodityIdx] = (oldTotal + cost) / float64(s.Cargo[commodityIdx]) + return nil +} + +// SellCommodity продаёт qty единиц товара commodityIdx по цене +// unitPrice за единицу. +func (s *WayfarerShip) SellCommodity(commodityIdx int, qty int, unitPrice float64) error { + if qty <= 0 { + return nil + } + if qty > s.Cargo[commodityIdx] { + return errWayfarerNoCargo + } + s.Cargo[commodityIdx] -= qty + s.Credits += unitPrice * float64(qty) + if s.Cargo[commodityIdx] == 0 { + s.CargoCostBasis[commodityIdx] = 0 // трюм по этому товару опустел — старая цена покупки больше не актуальна + } + return nil +} + +// Refuel заправляет корабль на amount единиц топлива (не больше +// полного бака) по цене pricePerUnit за единицу. +func (s *WayfarerShip) Refuel(amount float64, pricePerUnit float64) error { + if amount <= 0 { + return nil + } + if s.Fuel+amount > s.FuelCapacity { + amount = s.FuelCapacity - s.Fuel + } + cost := amount * pricePerUnit + if cost > s.Credits { + return errWayfarerInsufficientFunds + } + s.Credits -= cost + s.Fuel += amount + return nil +} + +// Repair чинит корпус на amount очков по цене pricePerPoint. +func (s *WayfarerShip) Repair(amount int, pricePerPoint float64) error { + if amount <= 0 { + return nil + } + if s.Hull+amount > s.MaxHull { + amount = s.MaxHull - s.Hull + } + cost := float64(amount) * pricePerPoint + if cost > s.Credits { + return errWayfarerInsufficientFunds + } + s.Credits -= cost + s.Hull += amount + return nil +} + +// JumpTo перемещает корабль в целевую систему, если хватает +// топлива, списывая расход по фактическому расстоянию. +func (s *WayfarerShip) JumpTo(target WayfarerSystemID) error { + if target == s.CurrentSystem { + return errWayfarerSameSystem + } + from := GenerateSystem(s.CurrentSystem) + to := GenerateSystem(target) + dist := from.Distance(to) + fuelNeeded := dist * s.FuelPerLY + if fuelNeeded > s.Fuel { + return errWayfarerOutOfRange + } + s.Fuel -= fuelNeeded + s.CurrentSystem = target + s.Visited[target] = true + return nil +} + +// wayfarerRatingThresholds — пороги числа побед для присвоения +// пилотского рейтинга (намёк на классическую систему рангов Elite — +// свой набор названий и порогов, не копия оригинальных чисел). +var wayfarerRatingThresholds = []struct { + Kills int + Key string +}{ + {0, "rating.harmless"}, + {4, "rating.mostly_harmless"}, + {12, "rating.poor"}, + {28, "rating.average"}, + {50, "rating.above_average"}, + {100, "rating.competent"}, + {200, "rating.dangerous"}, + {400, "rating.deadly"}, + {800, "rating.wayfarer_elite"}, +} + +// PilotRating возвращает ключ перевода текущего пилотского ранга. +func (s *WayfarerShip) PilotRating() string { + rating := wayfarerRatingThresholds[0].Key + for _, r := range wayfarerRatingThresholds { + if s.Kills >= r.Kills { + rating = r.Key + } + } + return rating +} + +// NetWorth — кредиты плюс примерная стоимость груза в трюме (по +// половине базовой цены каждого товара — грубая, но достаточная для +// отображения общего "веса" игрока оценка). +func (s *WayfarerShip) NetWorth() float64 { + worth := s.Credits + for i, n := range s.Cargo { + worth += float64(n) * WayfarerCommodities[i].BasePrice * 0.5 + } + return math.Round(worth) +} diff --git a/wayfarer_ship_test.go b/wayfarer_ship_test.go new file mode 100644 index 0000000..7e6d12e --- /dev/null +++ b/wayfarer_ship_test.go @@ -0,0 +1,191 @@ +package main + +import "testing" + +func TestNewWayfarerShipInitialState(t *testing.T) { + start := WayfarerSystemID{Galaxy: 0, Index: 0} + s := NewWayfarerShip(start) + if s.Credits != 1000 { + t.Errorf("ожидался стартовый капитал 1000, получено %v", s.Credits) + } + if s.CurrentSystem != start { + t.Errorf("корабль должен был начать в стартовой системе") + } + if !s.Visited[start] { + t.Errorf("стартовая система должна была отметиться как посещённая") + } + if s.CargoUsed() != 0 || s.CargoFree() != s.CargoCapacity { + t.Errorf("трюм новой партии должен быть пуст") + } +} + +func TestBuyAndSellCommodity(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + before := s.Credits + if err := s.BuyCommodity(0, 5, 10); err != nil { + t.Fatalf("неожиданная ошибка покупки: %v", err) + } + if s.Cargo[0] != 5 { + t.Errorf("ожидалось 5 единиц товара в трюме, получено %d", s.Cargo[0]) + } + if s.Credits != before-50 { + t.Errorf("ожидалось списание 50 кредитов, получено %v (было %v)", s.Credits, before) + } + + if err := s.SellCommodity(0, 5, 8); err != nil { + t.Fatalf("неожиданная ошибка продажи: %v", err) + } + if s.Cargo[0] != 0 { + t.Errorf("трюм должен был опустеть после продажи всего") + } + if s.Credits != before-50+40 { + t.Errorf("ожидалось начисление 40 кредитов от продажи, получено %v", s.Credits) + } +} + +func TestBuyCommodityInsufficientFunds(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Credits = 5 + if err := s.BuyCommodity(0, 10, 10); err != errWayfarerInsufficientFunds { + t.Errorf("ожидалась ошибка нехватки средств, получено: %v", err) + } +} + +func TestBuyCommodityCargoFull(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Credits = 1_000_000 + if err := s.BuyCommodity(0, s.CargoCapacity+1, 1); err != errWayfarerCargoFull { + t.Errorf("ожидалась ошибка переполнения трюма, получено: %v", err) + } +} + +func TestSellCommodityNotEnoughInCargo(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + if err := s.SellCommodity(0, 1, 10); err != errWayfarerNoCargo { + t.Errorf("ожидалась ошибка нехватки товара в трюме, получено: %v", err) + } +} + +func TestBuyCommodityTracksAverageCostBasis(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Credits = 1_000_000 + + if err := s.BuyCommodity(0, 10, 20); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.CargoCostBasis[0] != 20 { + t.Errorf("после первой покупки средняя цена должна быть 20, получено %v", s.CargoCostBasis[0]) + } + + // вторая партия по другой цене — средняя должна пересчитаться + // как взвешенное среднее: (10*20 + 10*40) / 20 = 30 + if err := s.BuyCommodity(0, 10, 40); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.CargoCostBasis[0] != 30 { + t.Errorf("ожидалась средняя цена 30 после второй закупки, получено %v", s.CargoCostBasis[0]) + } +} + +func TestSellCommodityResetsCostBasisWhenCargoEmpty(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Credits = 1_000_000 + s.BuyCommodity(0, 5, 20) + + if err := s.SellCommodity(0, 3, 25); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.CargoCostBasis[0] != 20 { + t.Errorf("частичная продажа не должна менять среднюю цену оставшегося груза, получено %v", s.CargoCostBasis[0]) + } + + if err := s.SellCommodity(0, 2, 25); err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if s.CargoCostBasis[0] != 0 { + t.Errorf("после полной продажи средняя цена должна была сброситься в 0, получено %v", s.CargoCostBasis[0]) + } +} + +func TestRefuelClampsAtCapacity(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Fuel = s.FuelCapacity - 1 + s.Credits = 1_000_000 + if err := s.Refuel(100, 1); err != nil { + t.Fatalf("неожиданная ошибка дозаправки: %v", err) + } + if s.Fuel != s.FuelCapacity { + t.Errorf("топливо должно было ограничиться ёмкостью бака, получено %v", s.Fuel) + } +} + +func TestRepairClampsAtMaxHull(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + s.Hull = s.MaxHull - 1 + s.Credits = 1_000_000 + if err := s.Repair(100, 1); err != nil { + t.Fatalf("неожиданная ошибка ремонта: %v", err) + } + if s.Hull != s.MaxHull { + t.Errorf("прочность должна была ограничиться максимумом, получено %d", s.Hull) + } +} + +func TestJumpToConsumesFuelByDistance(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{Galaxy: 0, Index: 0}) + target := WayfarerSystemID{Galaxy: 0, Index: 1} + before := s.Fuel + if err := s.JumpTo(target); err != nil { + t.Fatalf("неожиданная ошибка прыжка: %v", err) + } + if s.Fuel >= before { + t.Errorf("топливо должно было уменьшиться после прыжка") + } + if s.CurrentSystem != target { + t.Errorf("корабль должен был оказаться в целевой системе") + } + if !s.Visited[target] { + t.Errorf("целевая система должна была отметиться как посещённая") + } +} + +func TestJumpToSameSystemFails(t *testing.T) { + start := WayfarerSystemID{Galaxy: 0, Index: 0} + s := NewWayfarerShip(start) + if err := s.JumpTo(start); err != errWayfarerSameSystem { + t.Errorf("ожидалась ошибка прыжка в ту же систему, получено: %v", err) + } +} + +func TestJumpToOutOfRangeFails(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{Galaxy: 0, Index: 0}) + s.Fuel = 0.0001 + far := WayfarerSystemID{Galaxy: 0, Index: 1} + err := s.JumpTo(far) + if err != errWayfarerOutOfRange { + far = WayfarerSystemID{Galaxy: 0, Index: 50} + s2 := NewWayfarerShip(WayfarerSystemID{Galaxy: 0, Index: 0}) + s2.Fuel = 0.0001 + if err2 := s2.JumpTo(far); err2 != errWayfarerOutOfRange { + t.Fatalf("ожидалась ошибка нехватки топлива, получено: %v (и %v)", err, err2) + } + } +} + +func TestPilotRatingIncreasesWithKills(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + initial := s.PilotRating() + s.Kills = 500 + if s.PilotRating() == initial { + t.Errorf("рейтинг должен был вырасти после 500 побед") + } +} + +func TestNetWorthIncludesCargoValue(t *testing.T) { + s := NewWayfarerShip(WayfarerSystemID{}) + baseline := s.NetWorth() + s.Cargo[0] = 10 + if s.NetWorth() <= baseline { + t.Errorf("состояние должно было вырасти с добавлением груза в трюм") + } +} diff --git a/wayfarer_tui.go b/wayfarer_tui.go new file mode 100644 index 0000000..7332647 --- /dev/null +++ b/wayfarer_tui.go @@ -0,0 +1,927 @@ +package main + +import ( + "fmt" + "math/rand" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// wayfarerView — текущий экран игры. +type wayfarerView int + +const ( + wfViewHub wayfarerView = iota + wfViewMarket + wfViewShipyard + wfViewTravel + wfViewCombat + wfViewQuestList + wfViewQuestStep + wfViewQuestOffer + wfViewGameOver +) + +// wayfarerRefuelPrice / wayfarerRepairPrice — цены за единицу +// топлива/прочности корпуса на верфи (фиксированные — свои для +// каждой станции цены усложнили бы UI без особой пользы). +const ( + wayfarerRefuelPrice = 4.0 + wayfarerRepairPrice = 6.0 +) + +// wayfarerEquipmentCost — стоимость разового приобретения снаряжения. +var wayfarerEquipmentCost = map[WayfarerEquipment]float64{ + EquipFuelScoop: 800, + EquipCargoExpansion: 1200, + EquipShieldBooster: 1500, + EquipEscapePod: 2000, + EquipBetterLaser: 1800, +} + +var wayfarerEquipmentNameKey = map[WayfarerEquipment]string{ + EquipFuelScoop: "wf.equip.fuelscoop", + EquipCargoExpansion: "wf.equip.cargoexp", + EquipShieldBooster: "wf.equip.shieldbooster", + EquipEscapePod: "wf.equip.escapepod", + EquipBetterLaser: "wf.equip.betterlaser", +} + +var wayfarerEquipmentOrder = []WayfarerEquipment{ + EquipFuelScoop, EquipCargoExpansion, EquipShieldBooster, EquipEscapePod, EquipBetterLaser, +} + +// wayfarerStartSystem — фиксированная стартовая система новой +// карьеры (детерминированно — как "Lave" в классической Elite). +var wayfarerStartSystem = WayfarerSystemID{Galaxy: 0, Index: 0} + +// WayfarerModel — TUI поверх WayfarerShip. +type WayfarerModel struct { + ship *WayfarerShip + view wayfarerView + + combat *WayfarerCombatState + combatRewardCredits float64 // награда за успешный бой/сигнал бедствия, если применимо + + cursor int // курсор для рынка/верфи/перелёта/квестов (что уместно в текущем view) + + travelCandidates []WayfarerSystemID + + pendingQuestKey string // ключ квеста, предложенного встречей в пути, но ещё не принятого/отклонённого + + message string + isError bool + + rng *rand.Rand + + termWidth int // для переноса длинного текста квестов/сообщений по ширине реального терминала + + quitting bool + backToMenu bool +} + +// NewWayfarerModel загружает сохранённую карьеру, если она есть, или +// начинает новую с фиксированной стартовой системы. +func NewWayfarerModel() WayfarerModel { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + if ship, err := LoadWayfarerShip(); err == nil { + return WayfarerModel{ship: ship, view: wfViewHub, rng: rng, termWidth: 80} + } + return WayfarerModel{ship: NewWayfarerShip(wayfarerStartSystem), view: wfViewHub, rng: rng, termWidth: 80} +} + +func (m WayfarerModel) Init() tea.Cmd { return nil } + +func (m *WayfarerModel) setInfo(key string) { + m.message = T(key) + m.isError = false +} + +func (m *WayfarerModel) setInfof(key string, args ...interface{}) { + m.message = Tf(key, args...) + m.isError = false +} + +func (m *WayfarerModel) setError(err error) { + m.message = T(err.Error()) + m.isError = true +} + +func (m WayfarerModel) currentSystem() WayfarerSystem { + return GenerateSystem(m.ship.CurrentSystem) +} + +func (m WayfarerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { + m.termWidth = sizeMsg.Width + return m, nil + } + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + return m.handleKey(keyMsg) +} + +func (m WayfarerModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + if key == "ctrl+c" { + _ = SaveWayfarerShip(m.ship) + m.quitting = true + return m, tea.Quit + } + + switch m.view { + case wfViewHub: + return m.handleHubKey(key) + case wfViewMarket: + return m.handleMarketKey(key) + case wfViewShipyard: + return m.handleShipyardKey(key) + case wfViewTravel: + return m.handleTravelKey(key) + case wfViewCombat: + return m.handleCombatKey(key) + case wfViewQuestList: + return m.handleQuestListKey(key) + case wfViewQuestStep: + return m.handleQuestStepKey(key) + case wfViewQuestOffer: + return m.handleQuestOfferKey(key) + case wfViewGameOver: + return m.handleGameOverKey(key) + } + return m, nil +} + +// --- Хаб станции ----------------------------------------------------- + +func (m WayfarerModel) handleHubKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "q": + _ = SaveWayfarerShip(m.ship) + m.backToMenu = true + return m, nil + case "1": + m.view = wfViewMarket + m.cursor = 0 + m.message = "" + case "2": + m.view = wfViewShipyard + m.cursor = 0 + m.message = "" + case "3": + m.travelCandidates = wayfarerNearbySystems(m.ship.CurrentSystem, m.ship.FuelRange()) + m.view = wfViewTravel + m.cursor = 0 + m.message = "" + case "4": + m.view = wfViewQuestList + m.cursor = 0 + m.message = "" + } + return m, nil +} + +// --- Рынок ------------------------------------------------------------- + +func (m WayfarerModel) handleMarketKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "q", "esc": + m.view = wfViewHub + m.message = "" + return m, nil + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(WayfarerCommodities)-1 { + m.cursor++ + } + case "b", "s", "B", "S": + sys := m.currentSystem() + price := CommodityPrice(m.cursor, sys, m.rng) + switch key { + case "b": + if err := m.ship.BuyCommodity(m.cursor, 1, price); err != nil { + m.setError(err) + } else { + m.setInfof("wf.msg.bought", 1, T(WayfarerCommodities[m.cursor].Key)) + } + case "B": + qty := m.ship.CargoFree() + affordable := int(m.ship.Credits / price) + if affordable < qty { + qty = affordable + } + if err := m.ship.BuyCommodity(m.cursor, qty, price); err != nil { + m.setError(err) + } else { + m.setInfof("wf.msg.bought", qty, T(WayfarerCommodities[m.cursor].Key)) + } + case "s": + sellPrice := SellPrice(price) + if err := m.ship.SellCommodity(m.cursor, 1, sellPrice); err != nil { + m.setError(err) + } else { + m.setInfof("wf.msg.sold", 1, T(WayfarerCommodities[m.cursor].Key)) + } + case "S": + sellPrice := SellPrice(price) + qty := m.ship.Cargo[m.cursor] + if err := m.ship.SellCommodity(m.cursor, qty, sellPrice); err != nil { + m.setError(err) + } else { + m.setInfof("wf.msg.sold", qty, T(WayfarerCommodities[m.cursor].Key)) + } + } + } + return m, nil +} + +// --- Верфь --------------------------------------------------------------- + +func (m WayfarerModel) shipyardActionCount() int { + return 2 + len(wayfarerEquipmentOrder) // дозаправка + ремонт + снаряжение +} + +func (m WayfarerModel) handleShipyardKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "q", "esc": + m.view = wfViewHub + m.message = "" + return m, nil + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < m.shipyardActionCount()-1 { + m.cursor++ + } + case "enter", " ": + switch { + case m.cursor == 0: + need := m.ship.FuelCapacity - m.ship.Fuel + if err := m.ship.Refuel(need, wayfarerRefuelPrice); err != nil { + m.setError(err) + } else { + m.setInfo("wf.msg.refueled") + } + case m.cursor == 1: + need := m.ship.MaxHull - m.ship.Hull + if err := m.ship.Repair(need, wayfarerRepairPrice); err != nil { + m.setError(err) + } else { + m.setInfo("wf.msg.repaired") + } + default: + eq := wayfarerEquipmentOrder[m.cursor-2] + if m.ship.Equipment[eq] { + m.setError(errWayfarerAlreadyOwned) + return m, nil + } + cost := wayfarerEquipmentCost[eq] + if m.ship.Credits < cost { + m.setError(errWayfarerInsufficientFunds) + return m, nil + } + m.ship.Credits -= cost + m.ship.Equipment[eq] = true + applyWayfarerEquipmentEffect(m.ship, eq) + m.setInfo("wf.msg.equipped") + } + } + return m, nil +} + +// applyWayfarerEquipmentEffect применяет однократный эффект покупки +// снаряжения к статам корабля (там, где это применимо — щитовой +// бустер и улучшенный лазер сразу повышают соответствующие статы). +func applyWayfarerEquipmentEffect(s *WayfarerShip, eq WayfarerEquipment) { + switch eq { + case EquipShieldBooster: + s.MaxShield += 40 + s.Shield += 40 + case EquipBetterLaser: + s.LaserPower += 8 + case EquipCargoExpansion: + s.CargoCapacity += 15 + case EquipFuelScoop: + s.FuelCapacity += 3 + s.Fuel += 3 + } +} + +var errWayfarerAlreadyOwned = wayfarerErr("это снаряжение уже установлено") + +// --- Перелёт --------------------------------------------------------------- + +// wayfarerNearbySystems находит до 9 ближайших систем в пределах +// maxRange световых лет от current (в той же галактике), плюс — +// отдельным пунктом в конце списка — возможность гиперпрыжка в +// следующую галактику (см. render/handle travel). +func wayfarerNearbySystems(current WayfarerSystemID, maxRange float64) []WayfarerSystemID { + from := GenerateSystem(current) + type candidate struct { + id WayfarerSystemID + dist float64 + } + var candidates []candidate + for i := 0; i < WayfarerSystemsPerGalaxy; i++ { + if i == current.Index { + continue + } + id := WayfarerSystemID{Galaxy: current.Galaxy, Index: i} + dist := from.Distance(GenerateSystem(id)) + if dist <= maxRange { + candidates = append(candidates, candidate{id, dist}) + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].dist < candidates[j].dist }) + if len(candidates) > 9 { + candidates = candidates[:9] + } + out := make([]WayfarerSystemID, len(candidates)) + for i, c := range candidates { + out[i] = c.id + } + return out +} + +func (m WayfarerModel) handleTravelKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "q", "esc": + m.view = wfViewHub + m.message = "" + return m, nil + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + max := len(m.travelCandidates) // +1 пункт межгалактического прыжка обрабатывается отдельным индексом + if m.cursor < max { + m.cursor++ + } + case "enter", " ": + if m.cursor == len(m.travelCandidates) { + return m.performGalacticJump() + } + if m.cursor >= len(m.travelCandidates) { + return m, nil + } + target := m.travelCandidates[m.cursor] + return m.performJump(target) + } + return m, nil +} + +// performGalacticJump — межгалактический прыжок в случайную систему +// следующей галактики (по кругу после последней). Требует почти +// полного бака — платит собой весь текущий запас топлива. +func (m WayfarerModel) performGalacticJump() (tea.Model, tea.Cmd) { + if m.ship.Fuel < m.ship.FuelCapacity*0.9 { + m.setError(errWayfarerNeedFullTank) + return m, nil + } + nextGalaxy := (m.ship.CurrentSystem.Galaxy + 1) % WayfarerGalaxyCount + target := WayfarerSystemID{Galaxy: nextGalaxy, Index: m.rng.Intn(WayfarerSystemsPerGalaxy)} + m.ship.Fuel = 0 + m.ship.CurrentSystem = target + m.ship.Visited[target] = true + m.setInfo("wf.msg.galactic_jump") + m.view = wfViewHub + return m, nil +} + +var errWayfarerNeedFullTank = wayfarerErr("для межгалактического прыжка нужен полный (или почти полный) бак") + +// performJump выполняет обычный прыжок и разрешает случайную +// встречу в пути, если она произошла. +func (m WayfarerModel) performJump(target WayfarerSystemID) (tea.Model, tea.Cmd) { + if err := m.ship.JumpTo(target); err != nil { + m.setError(err) + return m, nil + } + + dest := GenerateSystem(target) + encounter := RollEncounter(dest, m.rng) + switch encounter { + case EncounterNone: + if questKey, ok := RollQuestOffer(m.ship, m.rng); ok { + m.pendingQuestKey = questKey + m.view = wfViewQuestOffer + m.message = "" + return m, nil + } + m.setInfo("wf.msg.jump_quiet") + m.view = wfViewHub + case EncounterPirate: + hull, laser := GeneratePirateStats(dest, m.rng) + m.combat = NewWayfarerCombat("wf.enemy.pirate", hull, laser, + m.ship.Hull, m.ship.MaxHull, m.ship.Shield, m.ship.MaxShield, m.ship.LaserPower, true) + m.combatRewardCredits = float64(hull) * 4 + m.view = wfViewCombat + m.setInfo("wf.msg.pirate_ambush") + case EncounterPoliceScan: + if m.ship.HasIllegalCargo() { + lost := m.ship.ConfiscateIllegalCargo() + m.setInfof("wf.msg.cargo_confiscated", formatWfCredits(lost)) + } else { + m.setInfo("wf.msg.scan_clear") + } + m.view = wfViewHub + case EncounterDistress: + reward, wasTrap := m.ship.HelpDistressed(m.rng) + if wasTrap { + hull, laser := GeneratePirateStats(dest, m.rng) + m.combat = NewWayfarerCombat("wf.enemy.raider", hull, laser, + m.ship.Hull, m.ship.MaxHull, m.ship.Shield, m.ship.MaxShield, m.ship.LaserPower, true) + m.combatRewardCredits = float64(hull) * 4 + m.view = wfViewCombat + m.setInfo("wf.msg.distress_trap") + } else { + m.setInfof("wf.msg.distress_reward", formatWfCredits(reward)) + m.view = wfViewHub + } + case EncounterDerelict: + found := m.ship.DerelictFind(m.rng) + m.setInfof("wf.msg.derelict_find", formatWfCredits(found)) + m.view = wfViewHub + } + return m, nil +} + +// --- Бой ------------------------------------------------------------------- + +func (m WayfarerModel) handleCombatKey(key string) (tea.Model, tea.Cmd) { + c := m.combat + if c == nil || c.Outcome != CombatOngoing { + return m, nil + } + switch key { + case "1": + c.Attack(m.rng) + case "2": + c.Flee(m.rng) + case "3": + if c.Negotiable { + c.Negotiate(m.rng) + } + default: + return m, nil + } + return m.resolveCombatIfDone() +} + +func (m WayfarerModel) resolveCombatIfDone() (tea.Model, tea.Cmd) { + c := m.combat + switch c.Outcome { + case CombatWon: + m.ship.Hull = c.PlayerHull + m.ship.Shield = c.PlayerShield + m.ship.Kills++ + m.ship.Credits += m.combatRewardCredits + m.setInfof("wf.msg.combat_won", formatWfCredits(m.combatRewardCredits)) + m.view = wfViewHub + m.combat = nil + case CombatFled: + m.ship.Hull = c.PlayerHull + m.ship.Shield = c.PlayerShield + m.setInfo("wf.msg.combat_fled") + m.view = wfViewHub + m.combat = nil + case CombatNegotiated: + m.ship.Hull = c.PlayerHull + m.ship.Shield = c.PlayerShield + m.setInfo("wf.msg.combat_negotiated") + m.view = wfViewHub + m.combat = nil + case CombatLost: + m.ship.Hull = 0 + if m.ship.Equipment[EquipEscapePod] { + m.ship.Equipment[EquipEscapePod] = false // одноразовое использование + m.ship.Hull = m.ship.MaxHull / 4 + m.ship.Shield = 0 + m.ship.Credits = m.ship.Credits / 2 + for i := range m.ship.Cargo { + m.ship.Cargo[i] = 0 + } + m.setInfo("wf.msg.escape_pod_used") + m.view = wfViewHub + m.combat = nil + } else { + m.view = wfViewGameOver + } + } + return m, nil +} + +// --- Список квестов ----------------------------------------------------- + +// availableQuests возвращает квесты, которые ещё можно предложить +// игроку (не завершены и не провалены) — используется только для +// подсчёта в статусе и функцией RollQuestOffer, свободного выбора +// из этого списка больше нет (квесты теперь предлагаются случайно +// при прибытии в систему). +func (m WayfarerModel) availableQuests() []WayfarerQuestDef { + var out []WayfarerQuestDef + for _, q := range WayfarerQuestDefs { + if !m.ship.CompletedQuests[q.Key] && !m.ship.FailedQuests[q.Key] { + out = append(out, q) + } + } + return out +} + +func (m WayfarerModel) handleQuestListKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "q", "esc": + m.view = wfViewHub + m.message = "" + case "enter", " ": + if m.ship.ActiveQuest != nil { + m.view = wfViewQuestStep + } + } + return m, nil +} + +func (m WayfarerModel) handleQuestOfferKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "1", "enter", " ": + questKey := m.pendingQuestKey + m.pendingQuestKey = "" + if err := m.ship.StartQuest(questKey); err != nil { + m.setError(err) + m.view = wfViewHub + return m, nil + } + m.view = wfViewQuestStep + case "2", "q", "esc": + m.pendingQuestKey = "" + m.setInfo("wf.quests.declined") + m.view = wfViewHub + } + return m, nil +} + +func (m WayfarerModel) viewQuestOffer() string { + var b strings.Builder + def, _ := FindWayfarerQuest(m.pendingQuestKey) + fmt.Fprintln(&b, titleStyle.Render(T("wf.quests.offer.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuCursorStyle.Render(T(def.TitleKey))) + fmt.Fprintln(&b, wfWrap(T(def.BriefKey), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.quests.offer.accept")) + fmt.Fprintln(&b, T("wf.quests.offer.decline")) + return b.String() +} + +func (m WayfarerModel) handleQuestStepKey(key string) (tea.Model, tea.Cmd) { + step, ok := m.ship.CurrentQuestStep() + if !ok { + m.view = wfViewQuestList + m.cursor = 0 + return m, nil + } + switch key { + case "q", "esc": + m.ship.AbandonQuest() + m.view = wfViewQuestList + m.cursor = 0 + return m, nil + } + for i := 1; i <= 9 && i <= len(step.Choices); i++ { + if key == fmt.Sprintf("%d", i) { + choice, err := m.ship.ChooseQuestOption(i - 1) + if err != nil { + m.setError(err) + return m, nil + } + if choice.ResultKey != "" { + m.message = T(choice.ResultKey) + m.isError = false + } + if m.ship.ActiveQuest == nil { + m.view = wfViewQuestList + m.cursor = 0 + } + return m, nil + } + } + return m, nil +} + +// --- Экран окончания карьеры (гибель без спасательной капсулы) -------- + +func (m WayfarerModel) handleGameOverKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "n": + _ = DeleteWayfarerSave() + m.ship = NewWayfarerShip(wayfarerStartSystem) + m.view = wfViewHub + m.message = "" + m.combat = nil + case "q": + _ = DeleteWayfarerSave() + m.backToMenu = true + } + return m, nil +} + +// --- Форматирование ----------------------------------------------------- + +func formatWfCredits(v float64) string { + return fmt.Sprintf("%.0f", v) +} + +// --- Отрисовка ------------------------------------------------------------ + +func (m WayfarerModel) View() string { + if m.quitting { + return T("common.goodbye") + } + switch m.view { + case wfViewMarket: + return m.viewMarket() + case wfViewShipyard: + return m.viewShipyard() + case wfViewTravel: + return m.viewTravel() + case wfViewCombat: + return m.viewCombat() + case wfViewQuestList: + return m.viewQuestList() + case wfViewQuestStep: + return m.viewQuestStep() + case wfViewQuestOffer: + return m.viewQuestOffer() + case wfViewGameOver: + return m.viewGameOver() + default: + return m.viewHub() + } +} + +func (m WayfarerModel) renderShipStatus() string { + s := m.ship + return Tf("wf.status_line", + formatWfCredits(s.Credits), s.CargoUsed(), s.CargoCapacity, + s.Fuel, s.FuelCapacity, s.Hull, s.MaxHull, s.Shield, s.MaxShield) +} + +// wfWrap переносит длинный текст (обычно — прозу квестов) по +// ширине реального терминала, чтобы строка не обрезалась и не +// вылезала за пределы экрана без переноса. Ширина ограничена +// разумным диапазоном: не уже 40 колонок (совсем узкие терминалы) и +// не шире 100 (сплошной текст на весь широкий терминал читать +// неудобно). +func wfWrap(text string, termWidth int) string { + width := termWidth - 4 + if width < 40 { + width = 60 // разумный запасной вариант, пока реальный размер ещё не пришёл через WindowSizeMsg + } + if width > 100 { + width = 100 + } + return lipgloss.NewStyle().Width(width).Render(text) +} + +func (m WayfarerModel) renderMessage(b *strings.Builder) { + if m.message == "" { + return + } + fmt.Fprintln(b) + if m.isError { + fmt.Fprintln(b, errorStyle.Render(wfWrap("! "+m.message, m.termWidth))) + } else { + fmt.Fprintln(b, infoStyle.Render(wfWrap(m.message, m.termWidth))) + } +} + +func (m WayfarerModel) viewHub() string { + sys := m.currentSystem() + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("wf.system_line", sys.Name, T(wayfarerGovernmentNameKeys[sys.Government]), sys.TechLevel)) + fmt.Fprintln(&b, m.renderShipStatus()) + fmt.Fprintln(&b, Tf("wf.rating_line", T(m.ship.PilotRating()), m.ship.Kills)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.hub.hint1")) + fmt.Fprintln(&b, T("wf.hub.hint2")) + m.renderMessage(&b) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render(T("wf.hint.quit"))) + return b.String() +} + +func (m WayfarerModel) viewMarket() string { + sys := m.currentSystem() + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.market.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderShipStatus()) + fmt.Fprintln(&b) + for i, c := range WayfarerCommodities { + price := CommodityPrice(i, sys, m.rng) + line := Tf("wf.market.line", T(c.Key), price, m.ship.Cargo[i]) + if m.ship.Cargo[i] > 0 { + line += Tf("wf.market.bought_at", m.ship.CargoCostBasis[i]) + } + if c.Illegal { + line += " " + dimStyle.Render(T("wf.market.illegal_tag")) + } + if i == m.cursor { + line = menuCursorStyle.Render("> " + line) + } else { + line = " " + line + } + fmt.Fprintln(&b, line) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.market.hint")) + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewShipyard() string { + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.shipyard.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, m.renderShipStatus()) + fmt.Fprintln(&b) + + lines := []string{ + Tf("wf.shipyard.refuel", (m.ship.FuelCapacity-m.ship.Fuel)*wayfarerRefuelPrice), + Tf("wf.shipyard.repair", float64(m.ship.MaxHull-m.ship.Hull)*wayfarerRepairPrice), + } + for _, eq := range wayfarerEquipmentOrder { + status := T("wf.shipyard.available") + if m.ship.Equipment[eq] { + status = T("wf.shipyard.owned") + } + lines = append(lines, Tf("wf.shipyard.equip_line", T(wayfarerEquipmentNameKey[eq]), wayfarerEquipmentCost[eq], status)) + } + for i, line := range lines { + if i == m.cursor { + fmt.Fprintln(&b, menuCursorStyle.Render("> "+line)) + } else { + fmt.Fprintln(&b, " "+line) + } + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.shipyard.hint")) + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewTravel() string { + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.travel.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("wf.travel.range", m.ship.FuelRange())) + fmt.Fprintln(&b) + + if len(m.travelCandidates) == 0 { + fmt.Fprintln(&b, dimStyle.Render(T("wf.travel.none_in_range"))) + } + for i, id := range m.travelCandidates { + sys := GenerateSystem(id) + dist := m.currentSystem().Distance(sys) + line := Tf("wf.travel.line", sys.Name, T(wayfarerGovernmentNameKeys[sys.Government]), dist) + if i == m.cursor { + line = menuCursorStyle.Render("> " + line) + } else { + line = " " + line + } + fmt.Fprintln(&b, line) + } + galacticLine := T("wf.travel.galactic_jump") + if m.cursor == len(m.travelCandidates) { + galacticLine = menuCursorStyle.Render("> " + galacticLine) + } else { + galacticLine = " " + galacticLine + } + fmt.Fprintln(&b, galacticLine) + + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.travel.hint")) + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewCombat() string { + c := m.combat + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.combat.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, Tf("wf.combat.enemy_line", T(c.EnemyNameKey), c.EnemyHull, c.EnemyMaxHull)) + fmt.Fprintln(&b, Tf("wf.combat.player_line", c.PlayerHull, c.PlayerMaxHull, c.PlayerShield, c.PlayerMaxShield)) + fmt.Fprintln(&b) + if c.Round > 0 { + fmt.Fprintln(&b, Tf("wf.combat.round_result", c.LastPlayerDamage, c.LastEnemyDamage)) + } + + switch c.Outcome { + case CombatOngoing: + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.combat.hint.attack")) + fmt.Fprintln(&b, T("wf.combat.hint.flee")) + if c.Negotiable { + fmt.Fprintln(&b, T("wf.combat.hint.negotiate")) + } + case CombatWon: + fmt.Fprintln(&b, winStyle.Render(T("wf.combat.outcome.won"))) + case CombatFled: + fmt.Fprintln(&b, infoStyle.Render(T("wf.combat.outcome.fled"))) + case CombatNegotiated: + fmt.Fprintln(&b, infoStyle.Render(T("wf.combat.outcome.negotiated"))) + case CombatLost: + fmt.Fprintln(&b, errorStyle.Render(T("wf.combat.outcome.lost"))) + } + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewQuestList() string { + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.quests.title"))) + fmt.Fprintln(&b) + + if m.ship.ActiveQuest != nil { + def, _ := FindWayfarerQuest(m.ship.ActiveQuest.Key) + fmt.Fprintln(&b, Tf("wf.quests.active", T(def.TitleKey))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.quests.hint.continue")) + m.renderMessage(&b) + return b.String() + } + + remaining := len(m.availableQuests()) + done := len(WayfarerQuestDefs) - remaining + fmt.Fprintln(&b, dimStyle.Render(Tf("wf.quests.progress", done, len(WayfarerQuestDefs)))) + fmt.Fprintln(&b) + if remaining == 0 { + fmt.Fprintln(&b, T("wf.quests.all_done")) + } else { + fmt.Fprintln(&b, T("wf.quests.none_active")) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.quests.hint.list")) + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewQuestStep() string { + var b strings.Builder + def, _ := FindWayfarerQuest(m.ship.ActiveQuest.Key) + step, ok := m.ship.CurrentQuestStep() + + fmt.Fprintln(&b, titleStyle.Render(T(def.TitleKey))) + fmt.Fprintln(&b) + + if !ok { + m.renderMessage(&b) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.quests.hint.back")) + return b.String() + } + + fmt.Fprintln(&b, wfWrap(T(step.TextKey), m.termWidth)) + fmt.Fprintln(&b) + for i, c := range step.Choices { + label := fmt.Sprintf("[%d] %s", i+1, T(c.LabelKey)) + fmt.Fprintln(&b, wfWrap(label, m.termWidth)) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("wf.quests.hint.abandon")) + m.renderMessage(&b) + return b.String() +} + +func (m WayfarerModel) viewGameOver() string { + var b strings.Builder + fmt.Fprintln(&b, titleStyle.Render(T("wf.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, errorStyle.Render(T("wf.gameover.destroyed"))) + fmt.Fprintln(&b, Tf("wf.gameover.stats", T(m.ship.PilotRating()), m.ship.Kills)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, dimStyle.Render(T("wf.gameover.hint"))) + return b.String() +} + +// wayfarerGovernmentNameKeys — ключи перевода названий правительств +// (индекс = значение WayfarerGovernment). +var wayfarerGovernmentNameKeys = [...]string{ + "wf.gov.anarchy", "wf.gov.feudal", "wf.gov.dictatorship", "wf.gov.communist", + "wf.gov.confederacy", "wf.gov.democracy", "wf.gov.corporate", +} diff --git a/wayfarer_tui_test.go b/wayfarer_tui_test.go new file mode 100644 index 0000000..8a87f5f --- /dev/null +++ b/wayfarer_tui_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestWfWrapSplitsLongText(t *testing.T) { + longText := "Это довольно длинный текст, который заведомо не поместится в одну строку узкого терминала и должен быть перенесён на несколько строк." + // termWidth=50 -> реальная ширина переноса 50-4=46 (выше минимума + // в 40, так что запасной вариант 60 тут не сработает) + wrapped := wfWrap(longText, 50) + lines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n") + if len(lines) < 2 { + t.Fatalf("ожидался перенос минимум на 2 строки, получена 1: %q", wrapped) + } + for _, line := range lines { + if n := utf8.RuneCountInString(line); n > 46 { + t.Errorf("строка длиннее заданной ширины 46 рун: %q (рун: %d)", line, n) + } + } +} + +func TestWfWrapShortTextStaysOnOneLine(t *testing.T) { + short := "Короткая фраза." + wrapped := wfWrap(short, 80) + lines := strings.Split(strings.TrimRight(wrapped, " \n"), "\n") + if len(lines) != 1 { + t.Errorf("короткий текст не должен переноситься, получено %d строк: %q", len(lines), wrapped) + } +} + +func TestWfWrapUsesFallbackWidthWhenTermWidthUnset(t *testing.T) { + longText := strings.Repeat("слово ", 30) + wrapped := wfWrap(longText, 0) + lines := strings.Split(strings.TrimRight(wrapped, " \n"), "\n") + if len(lines) < 2 { + t.Errorf("даже с запасной шириной длинный текст должен был перенестись, получено: %q", wrapped) + } +} + +func TestWayfarerQuestOfferAccept(t *testing.T) { + withTempConfigDir(t) + m := NewWayfarerModel() + m.pendingQuestKey = WayfarerQuestDefs[0].Key + m.view = wfViewQuestOffer + + next, _ := m.Update(key("1")) + m = next.(WayfarerModel) + if m.view != wfViewQuestStep { + t.Fatalf("после принятия задания должен был открыться экран его шага, получено view=%v", m.view) + } + if m.ship.ActiveQuest == nil || m.ship.ActiveQuest.Key != WayfarerQuestDefs[0].Key { + t.Fatalf("задание должно было стать активным") + } + if m.pendingQuestKey != "" { + t.Errorf("pendingQuestKey должен был очиститься после принятия") + } +} + +func TestWayfarerQuestOfferDecline(t *testing.T) { + withTempConfigDir(t) + m := NewWayfarerModel() + m.pendingQuestKey = WayfarerQuestDefs[0].Key + m.view = wfViewQuestOffer + + next, _ := m.Update(key("2")) + m = next.(WayfarerModel) + if m.view != wfViewHub { + t.Fatalf("после отказа должен был открыться хаб, получено view=%v", m.view) + } + if m.ship.ActiveQuest != nil { + t.Errorf("задание не должно было стать активным после отказа") + } + if m.pendingQuestKey != "" { + t.Errorf("pendingQuestKey должен был очиститься после отказа") + } +} + +func TestWfWrapClampsExcessiveWidth(t *testing.T) { + text := "текст" + wrapped := wfWrap(text, 500) + firstLine := strings.Split(wrapped, "\n")[0] + // при очень широком терминале перенос всё равно не должен + // растягиваться дальше 100 колонок (проверяем по длине padding'а, + // которым lipgloss дополняет строку до ширины стиля) + if n := utf8.RuneCountInString(firstLine); n > 100 { + t.Errorf("ширина не должна была превышать 100 колонок даже на широком терминале, получено %d", n) + } +}