Fixed some cosmetical trouble. Added chess, go, Corpse-Starch Box, Minesweeper, Wayfarer (elitelike) and English version
This commit is contained in:
parent
6dbf223cf5
commit
4a4fbb68da
71 changed files with 13624 additions and 412 deletions
147
README.en.md
Normal file
147
README.en.md
Normal file
|
|
@ -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=<number>` — no code changes needed.
|
||||
35
README.md
35
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=<число>` — без правки кода коллекции.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
58
bot.go
58
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ 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)
|
||||
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)
|
||||
)
|
||||
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
181
chess_bot.go
Normal file
181
chess_bot.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
61
chess_bot_test.go
Normal file
61
chess_bot_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
569
chess_game.go
Normal file
569
chess_game.go
Normal file
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
368
chess_game_test.go
Normal file
368
chess_game_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
322
chess_tui.go
Normal file
322
chess_tui.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
225
chess_tui_test.go
Normal file
225
chess_tui_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
312
corpsestarch_game.go
Normal file
312
corpsestarch_game.go
Normal file
|
|
@ -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
|
||||
}
|
||||
235
corpsestarch_game_test.go
Normal file
235
corpsestarch_game_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
91
corpsestarch_save.go
Normal file
91
corpsestarch_save.go
Normal file
|
|
@ -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
|
||||
}
|
||||
172
corpsestarch_save_test.go
Normal file
172
corpsestarch_save_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
285
corpsestarch_tui.go
Normal file
285
corpsestarch_tui.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
123
corpsestarch_tui_test.go
Normal file
123
corpsestarch_tui_test.go
Normal file
|
|
@ -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("счётчик завершённых миссий должен был увеличиться")
|
||||
}
|
||||
}
|
||||
24
durak_bot.go
24
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))]
|
||||
}
|
||||
|
|
|
|||
58
durak_tui.go
58
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))
|
||||
}
|
||||
|
|
|
|||
93
game.go
93
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 завершает раунд без победителя — колода и сброс
|
||||
// исчерпаны. Банк возвращается игрокам поровну (каждому — его
|
||||
// анте назад), чтобы никто не терял деньги в раунде без вины.
|
||||
|
|
|
|||
117
game_test.go
117
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
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
189
go_bot.go
Normal file
189
go_bot.go
Normal file
|
|
@ -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
|
||||
}
|
||||
98
go_bot_test.go
Normal file
98
go_bot_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
327
go_game.go
Normal file
327
go_game.go
Normal file
|
|
@ -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
|
||||
}
|
||||
317
go_game_test.go
Normal file
317
go_game_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
250
go_tui.go
Normal file
250
go_tui.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
139
go_tui_test.go
Normal file
139
go_tui_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
56
i18n.go
Normal file
56
i18n.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
37
main.go
37
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,13 +47,24 @@ 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()
|
||||
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)")
|
||||
|
|
@ -54,15 +72,18 @@ func printHelp() {
|
|||
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 показать эту справку и правила на русском")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
865
menu_test.go
865
menu_test.go
File diff suppressed because it is too large
Load diff
300
minesweeper_game.go
Normal file
300
minesweeper_game.go
Normal file
|
|
@ -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
|
||||
}
|
||||
326
minesweeper_game_test.go
Normal file
326
minesweeper_game_test.go
Normal file
|
|
@ -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("неверно поставленный флаг при хорде должен был привести к проигрышу")
|
||||
}
|
||||
}
|
||||
51
minesweeper_stress_test.go
Normal file
51
minesweeper_stress_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
minesweeper_tui.go
Normal file
229
minesweeper_tui.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
179
minesweeper_tui_test.go
Normal file
179
minesweeper_tui_test.go
Normal file
|
|
@ -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("партия должна была завершиться после перебора всех клеток")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
BIN
tonk
Executable file
BIN
tonk
Executable file
Binary file not shown.
406
translations.go
Normal file
406
translations.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
95
translations_wayfarer1.go
Normal file
95
translations_wayfarer1.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
88
translations_wayfarer2.go
Normal file
88
translations_wayfarer2.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
174
translations_wayfarer3.go
Normal file
174
translations_wayfarer3.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
157
translations_wayfarer4.go
Normal file
157
translations_wayfarer4.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
130
tui.go
130
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")
|
||||
}
|
||||
|
|
|
|||
50
tui_test.go
50
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)}
|
||||
|
|
|
|||
165
wayfarer_combat.go
Normal file
165
wayfarer_combat.go
Normal file
|
|
@ -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
|
||||
}
|
||||
142
wayfarer_combat_test.go
Normal file
142
wayfarer_combat_test.go
Normal file
|
|
@ -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("действия после завершения боя не должны ничего менять")
|
||||
}
|
||||
}
|
||||
56
wayfarer_economy.go
Normal file
56
wayfarer_economy.go
Normal file
|
|
@ -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
|
||||
}
|
||||
80
wayfarer_economy_test.go
Normal file
80
wayfarer_economy_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
148
wayfarer_encounters.go
Normal file
148
wayfarer_encounters.go
Normal file
|
|
@ -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
|
||||
}
|
||||
177
wayfarer_encounters_test.go
Normal file
177
wayfarer_encounters_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
76
wayfarer_env.go
Normal file
76
wayfarer_env.go
Normal file
|
|
@ -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
|
||||
}
|
||||
160
wayfarer_env_test.go
Normal file
160
wayfarer_env_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
175
wayfarer_galaxy.go
Normal file
175
wayfarer_galaxy.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
76
wayfarer_galaxy_test.go
Normal file
76
wayfarer_galaxy_test.go
Normal file
|
|
@ -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("анархия должна быть опаснее корпоративного государства")
|
||||
}
|
||||
}
|
||||
150
wayfarer_quest_state.go
Normal file
150
wayfarer_quest_state.go
Normal file
|
|
@ -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("недопустимый номер варианта")
|
||||
)
|
||||
182
wayfarer_quest_state_test.go
Normal file
182
wayfarer_quest_state_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
339
wayfarer_quests.go
Normal file
339
wayfarer_quests.go
Normal file
|
|
@ -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},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
126
wayfarer_quests_test.go
Normal file
126
wayfarer_quests_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
wayfarer_save.go
Normal file
94
wayfarer_save.go
Normal file
|
|
@ -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
|
||||
}
|
||||
244
wayfarer_ship.go
Normal file
244
wayfarer_ship.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
191
wayfarer_ship_test.go
Normal file
191
wayfarer_ship_test.go
Normal file
|
|
@ -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("состояние должно было вырасти с добавлением груза в трюм")
|
||||
}
|
||||
}
|
||||
927
wayfarer_tui.go
Normal file
927
wayfarer_tui.go
Normal file
|
|
@ -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",
|
||||
}
|
||||
91
wayfarer_tui_test.go
Normal file
91
wayfarer_tui_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue