diff --git a/checkers_tui.go b/checkers_tui.go index 73a0031..701f050 100644 --- a/checkers_tui.go +++ b/checkers_tui.go @@ -42,11 +42,15 @@ type CheckersModel struct { // значение по умолчанию из CheckersBot). func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel { human := resolveCheckersColor(colorChoice) + startRow := 5 + if human == CheckersBlack { + startRow = 2 + } return CheckersModel{ game: NewCheckersGame(), bot: CheckersBot{Depth: depth}, human: human, - cursor: CheckersPos{Row: 5, Col: 0}, + cursor: CheckersPos{Row: startRow, Col: 0}, } } @@ -142,21 +146,38 @@ func (m CheckersModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + flip := m.human == CheckersBlack switch key { case "up", "k": - if m.cursor.Row > 0 { + if flip { + if m.cursor.Row < 7 { + m.cursor.Row++ + } + } else if m.cursor.Row > 0 { m.cursor.Row-- } case "down", "j": - if m.cursor.Row < 7 { + if flip { + if m.cursor.Row > 0 { + m.cursor.Row-- + } + } else if m.cursor.Row < 7 { m.cursor.Row++ } case "left", "h": - if m.cursor.Col > 0 { + if flip { + if m.cursor.Col < 7 { + m.cursor.Col++ + } + } else if m.cursor.Col > 0 { m.cursor.Col-- } case "right", "l": - if m.cursor.Col < 7 { + if flip { + if m.cursor.Col > 0 { + m.cursor.Col-- + } + } else if m.cursor.Col < 7 { m.cursor.Col++ } case "esc": @@ -249,11 +270,30 @@ func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool { func (m CheckersModel) renderBoard() string { dests := m.legalDestinationSet() + flip := m.human == CheckersBlack var b strings.Builder - fmt.Fprintln(&b, " a b c d e f g h") - for r := 0; r < 8; r++ { + + fmt.Fprint(&b, " ") + for cc := 0; cc < 8; cc++ { + col := cc + if flip { + col = 7 - cc + } + fmt.Fprintf(&b, "%c ", 'a'+col) + } + fmt.Fprintln(&b) + + for rr := 0; rr < 8; rr++ { + r := rr + if flip { + r = 7 - rr + } fmt.Fprintf(&b, "%d ", 8-r) - for c := 0; c < 8; c++ { + for cc := 0; cc < 8; cc++ { + c := cc + if flip { + c = 7 - cc + } pos := CheckersPos{Row: r, Col: c} cellStyle := checkersLightSquare if (r+c)%2 == 1 { diff --git a/checkers_tui_test.go b/checkers_tui_test.go index bb396dc..3df8fa1 100644 --- a/checkers_tui_test.go +++ b/checkers_tui_test.go @@ -228,3 +228,39 @@ func TestNewCheckersModelRandomColorGivesValidColor(t *testing.T) { t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) } } + +func TestCheckersBoardNotFlippedForWhite(t *testing.T) { + m := NewCheckersModel(0, ColorChoiceA) + if m.cursor.Row != 5 { + t.Errorf("за белых курсор должен был стартовать в ряду 5 (своя сторона), получено %d", m.cursor.Row) + } + view := m.renderBoard() + lines := strings.Split(view, "\n") + if !strings.HasPrefix(lines[8], "1 ") { + t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8]) + } +} + +func TestCheckersBoardFlippedForBlack(t *testing.T) { + m := NewCheckersModel(0, ColorChoiceB) + if m.cursor.Row != 2 { + t.Errorf("за чёрных курсор должен был стартовать в ряду 2 (своя сторона), получено %d", m.cursor.Row) + } + view := m.renderBoard() + lines := strings.Split(view, "\n") + if !strings.HasPrefix(lines[8], "8 ") { + t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8]) + } +} + +func TestCheckersCursorMovementFlippedForBlack(t *testing.T) { + m := NewCheckersModel(0, ColorChoiceB) + m.game.Turn = m.human + startRow := m.cursor.Row + + next, _ := m.Update(key("up")) + m2 := next.(CheckersModel) + if m2.cursor.Row != startRow+1 { + t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow) + } +} diff --git a/chess_tui.go b/chess_tui.go index 4704fc3..d7bf308 100644 --- a/chess_tui.go +++ b/chess_tui.go @@ -32,11 +32,15 @@ type ChessModel struct { // глубина поиска бота в полуходах (0 -> значение по умолчанию). func NewChessModel(depth int, colorChoice ColorChoice) ChessModel { human := resolveChessColor(colorChoice) + startRow := 7 + if human == ChessBlack { + startRow = 0 + } return ChessModel{ game: NewChessGame(), bot: ChessBot{Depth: depth}, human: human, - cursor: ChessPos{Row: 7, Col: 4}, + cursor: ChessPos{Row: startRow, Col: 4}, } } @@ -132,21 +136,38 @@ func (m ChessModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + flip := m.human == ChessBlack switch key { case "up", "k": - if m.cursor.Row > 0 { + if flip { + if m.cursor.Row < 7 { + m.cursor.Row++ + } + } else if m.cursor.Row > 0 { m.cursor.Row-- } case "down", "j": - if m.cursor.Row < 7 { + if flip { + if m.cursor.Row > 0 { + m.cursor.Row-- + } + } else if m.cursor.Row < 7 { m.cursor.Row++ } case "left", "h": - if m.cursor.Col > 0 { + if flip { + if m.cursor.Col < 7 { + m.cursor.Col++ + } + } else if m.cursor.Col > 0 { m.cursor.Col-- } case "right", "l": - if m.cursor.Col < 7 { + if flip { + if m.cursor.Col > 0 { + m.cursor.Col-- + } + } else if m.cursor.Col < 7 { m.cursor.Col++ } case "esc": @@ -251,11 +272,30 @@ func (m ChessModel) legalDestinationSet() map[ChessPos]bool { func (m ChessModel) renderBoard() string { dests := m.legalDestinationSet() + flip := m.human == ChessBlack var b strings.Builder - fmt.Fprintln(&b, " a b c d e f g h") - for r := 0; r < 8; r++ { + + fmt.Fprint(&b, " ") + for cc := 0; cc < 8; cc++ { + col := cc + if flip { + col = 7 - cc + } + fmt.Fprintf(&b, "%c ", 'a'+col) + } + fmt.Fprintln(&b) + + for rr := 0; rr < 8; rr++ { + r := rr + if flip { + r = 7 - rr + } fmt.Fprintf(&b, "%d ", 8-r) - for c := 0; c < 8; c++ { + for cc := 0; cc < 8; cc++ { + c := cc + if flip { + c = 7 - cc + } pos := ChessPos{Row: r, Col: c} cellStyle := checkersLightSquare if (r+c)%2 == 1 { diff --git a/chess_tui_test.go b/chess_tui_test.go index 5a395cc..b7f3c69 100644 --- a/chess_tui_test.go +++ b/chess_tui_test.go @@ -252,3 +252,50 @@ func TestNewChessModelRandomColorGivesValidColor(t *testing.T) { t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) } } + +func TestChessBoardNotFlippedForWhite(t *testing.T) { + m := NewChessModel(0, ColorChoiceA) // белые + if m.cursor.Row != 7 { + t.Errorf("за белых курсор должен был стартовать в ряду 7 (своя сторона), получено %d", m.cursor.Row) + } + view := m.renderBoard() + lines := strings.Split(view, "\n") + // последняя содержательная строка доски (перед пустой) должна + // начинаться с "1" (ряд белых снизу) без разворота + if !strings.HasPrefix(lines[8], "1 ") { + t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8]) + } +} + +func TestChessBoardFlippedForBlack(t *testing.T) { + m := NewChessModel(0, ColorChoiceB) // чёрные + if m.cursor.Row != 0 { + t.Errorf("за чёрных курсор должен был стартовать в ряду 0 (своя сторона), получено %d", m.cursor.Row) + } + view := m.renderBoard() + lines := strings.Split(view, "\n") + // доска развёрнута — нижний ряд теперь подписан 8 (ряд чёрных) + if !strings.HasPrefix(lines[8], "8 ") { + t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8]) + } + // и заголовок с буквами файлов тоже развёрнут: h идёт первой + if !strings.Contains(lines[0], "h") || strings.Index(lines[0], "h") > strings.Index(lines[0], "a") { + t.Errorf("за чёрных буквы файлов в заголовке тоже должны были развернуться, получено: %q", lines[0]) + } +} + +func TestChessCursorMovementFlippedForBlack(t *testing.T) { + m := NewChessModel(0, ColorChoiceB) + m.game.Turn = m.human // ход человека, чтобы клавиши обрабатывались + startRow := m.cursor.Row + + next, _ := m.Update(key("up")) + m2 := next.(ChessModel) + // на развёрнутой доске "вверх" должно визуально уводить курсор + // туда же, куда он был бы направлен на обычной доске — то есть + // в сторону дальнего от игрока ряда, что при развороте означает + // увеличение Row, а не уменьшение + if m2.cursor.Row != startRow+1 { + t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow) + } +} diff --git a/ggc b/ggc index fead10e..c191de3 100755 --- a/ggc +++ b/ggc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:814093914a7b7c80c42934319693b2037ee54acae969273174c9db019c837035 -size 5140642 +oid sha256:32a3a0e4e493d6f7e4e65dbfe5524388e8b057c123b58a1600323542c3207467 +size 5152930 diff --git a/ggc.exe b/ggc.exe index 238f4a6..f4ba131 100755 --- a/ggc.exe +++ b/ggc.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ddba822d485c8ddbcf6c62d0177d7bcd80df00e356e480df342ad8bece04011 -size 5432832 +oid sha256:4450ec70115539f2632ab1ba78343eab34b35e58b8d838de5f631f6b64f000c1 +size 5442048 diff --git a/menu.go b/menu.go index 9edb9bc..69dcecb 100644 --- a/menu.go +++ b/menu.go @@ -234,8 +234,10 @@ const ( stateStoryHub stateStoryConditions stateStoryUseItem + stateStorySurvivalAttempt stateStoryEscapeAttempt stateStoryEscapeEnding + stateStoryTrueEnding stateMenu stateRulesMenu stateRules @@ -764,6 +766,8 @@ type AppModel struct { storyGreeting storyGreetingKind storyPostGameFlavor string storyConfirmingReset bool + cheatArmed bool + cheatBuffer string storyHubCursor int storyActiveResource *StoryResourceID cursor int @@ -820,6 +824,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } + if handled, next, cmd := m.handleStoryCheatCode(msg); handled { + return next, cmd + } if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { m.termWidth = sizeMsg.Width m.termHeight = sizeMsg.Height @@ -862,10 +869,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateStoryConditions(msg) case stateStoryUseItem: return m.updateStoryUseItem(msg) + case stateStorySurvivalAttempt: + return m.updateStorySurvivalAttempt(msg) case stateStoryEscapeAttempt: return m.updateStoryEscapeAttempt(msg) case stateStoryEscapeEnding: return m.updateStoryEscapeEnding(msg) + case stateStoryTrueEnding: + return m.updateStoryTrueEnding(msg) case stateRulesMenu: return m.updateRulesMenu(msg) case stateRules: @@ -1611,36 +1622,12 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { if backToMenu { if m.storyActiveResource != nil { id := *m.storyActiveResource + won := false if def, ok := storyGameDefs[id]; ok { - if finished, won := def.Outcome(m.activeGame); finished && won { - if id == StoryResKlondike { - // у Косынки особое правило — 3 победы за - // один день, а не начисление на каждую; - // суточный лимит начислений (AwardResource) - // проверяется уже поверх этого правила - m.story.KlondikeWinsToday++ - if m.story.KlondikeWinsToday == 3 { - m.story.AwardResource(id) - } - } else { - m.story.AwardResource(id) - } - } + finished, w := def.Outcome(m.activeGame) + won = finished && w } - m.storyActiveResource = nil - m.activeGame = nil - if m.story.AdvanceOnboarding() { - // именно этим исходом партии знакомство завершилось — - // показываем приветствие один-единственный раз, и уже - // после него откроется обычный лагерь целиком - _ = SaveStoryModeState(m.story) - m.state = stateStoryWelcome - m.storyWelcomeWordsShown = 0 - return m, storyWelcomeTick() - } - m.storyPostGameFlavor = randomStoryPostGameFlavor() - m = m.enterStoryHub() - return m, nil + return m.storyProcessGameOutcome(id, won) } m.state = stateMenu m.activeGame = nil @@ -1649,6 +1636,94 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// storyProcessGameOutcome — начисляет ресурс (если won) за только +// что законченную партию сторимода и продвигает знакомство, если +// применимо. Общий код для обычного возврата из партии и для +// чит-кода принудительной победы (см. handleStoryCheatCode) — оба +// должны вести себя одинаково. +func (m AppModel) storyProcessGameOutcome(id StoryResourceID, won bool) (tea.Model, tea.Cmd) { + if won { + if id == StoryResKlondike { + // у Косынки особое правило — 3 победы за один день, а не + // начисление на каждую; суточный лимит начислений + // (AwardResource) проверяется уже поверх этого правила + m.story.KlondikeWinsToday++ + if m.story.KlondikeWinsToday == 3 { + m.story.AwardResource(id) + } + } else { + m.story.AwardResource(id) + } + } + m.storyActiveResource = nil + m.activeGame = nil + if m.story.AdvanceOnboarding() { + // именно этим исходом партии знакомство завершилось — + // показываем приветствие один-единственный раз, и уже + // после него откроется обычный бункер целиком + _ = SaveStoryModeState(m.story) + m.state = stateStoryWelcome + m.storyWelcomeWordsShown = 0 + return m, storyWelcomeTick() + } + if m.story.Complete() { + // все 15 ресурсов заперты — истинная концовка; сохранение + // удалится при выходе с этого экрана (см. updateStoryTrueEnding) + _ = SaveStoryModeState(m.story) + m.state = stateStoryTrueEnding + return m, nil + } + m.storyPostGameFlavor = randomStoryPostGameFlavor() + m = m.enterStoryHub() + return m, nil +} + +// handleStoryCheatCode — читкод для тестирования: во время активной +// партии сторимода (statePlaying, есть привязанный storyActiveResource) +// двоеточие вооружает ввод, дальше три "7" подряд засчитывают победу +// в ТЕКУЩЕЙ игре, как если бы она реально была выиграна. Любая другая +// клавиша во время вооружённого ввода отменяет попытку. Возвращает +// handled=true, если клавиша была перехвачена читом (и тогда её не +// нужно передавать дальше по обычной цепочке обработки). +func (m AppModel) handleStoryCheatCode(msg tea.Msg) (handled bool, next tea.Model, cmd tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return false, m, nil + } + key := keyMsg.String() + + if !m.cheatArmed { + if key == ":" && m.state == statePlaying && m.storyActiveResource != nil { + m.cheatArmed = true + m.cheatBuffer = "" + return true, m, nil + } + return false, m, nil + } + + // уже вооружено — эта клавиша так или иначе про чит-код, наружу + // (в текущую игру) её не пускаем + switch key { + case ":": + m.cheatBuffer = "" // начать ввод заново + return true, m, nil + case "7": + m.cheatBuffer += "7" + if m.cheatBuffer != "777" { + return true, m, nil + } + m.cheatArmed = false + m.cheatBuffer = "" + id := *m.storyActiveResource + next, cmd := m.storyProcessGameOutcome(id, true) + return true, next, cmd + default: + m.cheatArmed = false + m.cheatBuffer = "" + return true, m, nil + } +} + func (m AppModel) View() string { if m.quitting { return T("common.goodbye") @@ -1676,10 +1751,14 @@ func (m AppModel) View() string { return m.viewStoryConditions() case stateStoryUseItem: return m.viewStoryUseItem() + case stateStorySurvivalAttempt: + return m.viewStorySurvivalAttempt() case stateStoryEscapeAttempt: return m.viewStoryEscapeAttempt() case stateStoryEscapeEnding: return m.viewStoryEscapeEnding() + case stateStoryTrueEnding: + return m.viewStoryTrueEnding() case stateRulesMenu: return m.viewRulesMenu() case stateRules: @@ -1727,7 +1806,7 @@ func (m AppModel) viewLanguage() string { fmt.Fprintf(&b, "%s%s\n", cursor, label) } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("lang.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("lang.hint"), m.termWidth))) return b.String() } @@ -1747,7 +1826,7 @@ func (m AppModel) viewModeSelect() string { fmt.Fprintf(&b, "%s%s\n", cursor, label) } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth))) return b.String() } @@ -1791,7 +1870,7 @@ func (m AppModel) viewMenu() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("menu.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("menu.hint"), m.termWidth))) return b.String() } @@ -1825,7 +1904,7 @@ func (m AppModel) viewRulesMenu() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("rulesmenu.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("rulesmenu.hint"), m.termWidth))) return b.String() } @@ -1851,7 +1930,7 @@ func (m AppModel) viewRules() string { if maxScroll > 0 { scrollInfo = " " + Tf("rules.scroll_info", start+1, end, len(lines)) } - fmt.Fprintln(&b, menuHintStyle.Render(T("rules.hint")+scrollInfo)) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("rules.hint")+scrollInfo, m.termWidth))) return b.String() } @@ -1886,7 +1965,7 @@ func (m AppModel) viewSettings() string { T("stake.pot_info"), pot, T("stake.tonk_win"), m.settings.multiplier(), pot*m.settings.multiplier()))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("stake.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("stake.hint"), m.termWidth))) return b.String() } @@ -1899,7 +1978,7 @@ func (m AppModel) viewPlayerCount() string { fmt.Fprintln(&b) fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("playercount.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("playercount.hint"), m.termWidth))) return b.String() } @@ -1936,7 +2015,7 @@ func (m AppModel) viewBlackjackSettings() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("bjsettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("bjsettings.hint"), m.termWidth))) return b.String() } @@ -1964,7 +2043,7 @@ func (m AppModel) viewCheckersSettings() string { fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -1985,7 +2064,7 @@ func (m AppModel) viewTicTacToeSettings() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("checkerslvl.hint"), m.termWidth))) return b.String() } @@ -2017,7 +2096,7 @@ func (m AppModel) viewNardySettings() string { desc := nardyDifficultyLevels[m.nardy.difficultyIdx].DescKey fmt.Fprintln(&b, dimStyle.Render(T(desc))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -2045,7 +2124,7 @@ func (m AppModel) viewUrSettings() string { fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -2073,7 +2152,7 @@ func (m AppModel) viewSenetSettings() string { fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -2101,7 +2180,7 @@ func (m AppModel) viewChessSettings() string { fmt.Fprintln(&b) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -2130,7 +2209,7 @@ func (m AppModel) viewGoSettings() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth))) return b.String() } @@ -2151,6 +2230,6 @@ func (m AppModel) viewMinesweeperSettings() string { } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("mssettings.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("mssettings.hint"), m.termWidth))) return b.String() } \ No newline at end of file diff --git a/menu_test.go b/menu_test.go index c9722cd..1c9cae5 100644 --- a/menu_test.go +++ b/menu_test.go @@ -1714,4 +1714,4 @@ func TestLowercaseQDoesNotQuitAppFromModeSelect(t *testing.T) { if m.state != stateLanguage { t.Errorf("строчная q должна была вернуть на экран выбора языка, получено %v", m.state) } -} \ No newline at end of file +} diff --git a/nardy_tui.go b/nardy_tui.go index f4e8a06..2570d26 100644 --- a/nardy_tui.go +++ b/nardy_tui.go @@ -284,7 +284,11 @@ func (m NardyModel) View() string { nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack])) fmt.Fprintln(&b) - you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor)) + colorWord := T("color.white") + if m.humanColor == NardyBlack { + colorWord = T("color.black") + } + you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor)+" "+colorWord) fmt.Fprintln(&b, dimStyle.Render(you)) switch m.game.Phase { diff --git a/nardy_tui_test.go b/nardy_tui_test.go index 827d5d0..69e9011 100644 --- a/nardy_tui_test.go +++ b/nardy_tui_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "testing" ) @@ -287,3 +288,17 @@ func TestNewNardyModelRandomColorGivesValidColor(t *testing.T) { t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) } } + +func TestNardyViewNamesPlayerColorInWords(t *testing.T) { + mWhite := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA) + view := mWhite.View() + if !strings.Contains(view, T("color.white")) { + t.Errorf("за белых в подписи должно было явно упоминаться слово 'Белые', получено:\n%s", view) + } + + mBlack := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceB) + view = mBlack.View() + if !strings.Contains(view, T("color.black")) { + t.Errorf("за чёрных в подписи должно было явно упоминаться слово 'Чёрные', получено:\n%s", view) + } +} diff --git a/story_flow_test.go b/story_flow_test.go index 8e3f6b7..9f0fc7e 100644 --- a/story_flow_test.go +++ b/story_flow_test.go @@ -1222,4 +1222,369 @@ func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) { if m.state != stateStoryHub { t.Errorf("ожидался переход в хаб, получено %v", m.state) } +} + +// TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit — регрессия: +// раньше суточный лимит начислений (StoryDailyGrantsLimit = 3) +// применялся и во время знакомства, а оно требует 4 начисления +// подряд (3 победы в крестики-нолики + 1 в шашки) — если сыграть всё +// это за один день, четвёртое начисление (победа в шашках) просто не +// проходило, и знакомство зависало навсегда на этапе "+ Шашки". +func TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub + + ttt := NewTicTacToeModel(TicTacToeDifficultyEasy) + ttt.game.Phase = TicTacToePhaseOver + ttt.game.Result = &TicTacToeResult{Winner: ttt.game.HumanMark} + ttt.backToMenu = true + for i := 0; i < 3; i++ { + resource := StoryResTicTacToe + m.storyActiveResource = &resource + m.activeGame = ttt + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + } + if m.story.Onboarding != StoryOnboardingWithCheckers { + t.Fatalf("после 3 побед в крестики-нолики ожидался переход на этап WithCheckers, получено %v", m.story.Onboarding) + } + if m.story.DailyGrantsUsed[StoryResTicTacToe] != StoryDailyGrantsLimit { + t.Fatalf("три победы должны были исчерпать суточный лимит именно крестиков-ноликов (%d), получено %d", StoryDailyGrantsLimit, m.story.DailyGrantsUsed[StoryResTicTacToe]) + } + + cm := NewCheckersModel(0, ColorChoiceA) + cm.game.Result = &CheckersResult{Winner: cm.human} + cm.backToMenu = true + resource := StoryResCheckers + m.storyActiveResource = &resource + m.activeGame = cm + m.state = statePlaying + next, _ := m.Update(key("q")) + m = next.(AppModel) + + if m.story.Onboarding != StoryOnboardingDone { + t.Errorf("победа в шашках в тот же день должна была завершить знакомство несмотря на исчерпанный суточный лимит, получено %v", m.story.Onboarding) + } +} + +func TestCheatCodeArmsOnlyDuringActiveStoryGame(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.state = stateStoryHub // не во время партии + + next, _ := m.Update(key(":")) + m = next.(AppModel) + if m.cheatArmed { + t.Errorf("двоеточие вне активной партии не должно было вооружить чит-код") + } +} + +func TestCheatCodeTripleSevenForcesWin(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + before := m.story.Resources[StoryResChess] + + resource := StoryResChess + m.storyActiveResource = &resource + m.activeGame = NewChessModel(0, ColorChoiceA) // партия и близко не завершена + m.state = statePlaying + + next, _ := m.Update(key(":")) + m = next.(AppModel) + if !m.cheatArmed { + t.Fatalf("двоеточие во время активной партии сторимода должно было вооружить чит-код") + } + + for i := 0; i < 3; i++ { + next, _ = m.Update(key("7")) + m = next.(AppModel) + } + + if m.cheatArmed { + t.Errorf("после ввода 777 чит-код должен был сработать и разоружиться") + } + if m.state != stateStoryHub { + t.Fatalf("после чит-кода ожидался возврат в хаб, получено %v", m.state) + } + if m.story.Resources[StoryResChess] != before+1 { + t.Errorf("чит-код должен был засчитать победу в шахматах, получено %d (было %d)", m.story.Resources[StoryResChess], before) + } + if m.storyActiveResource != nil { + t.Errorf("отметка активного ресурса должна была сброситься после чит-кода") + } +} + +func TestCheatCodeWrongDigitCancels(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + before := m.story.Resources[StoryResChess] + + resource := StoryResChess + m.storyActiveResource = &resource + m.activeGame = NewChessModel(0, ColorChoiceA) + m.state = statePlaying + + next, _ := m.Update(key(":")) + m = next.(AppModel) + next, _ = m.Update(key("7")) + m = next.(AppModel) + next, _ = m.Update(key("8")) // не та цифра — отменяет попытку + m = next.(AppModel) + + if m.cheatArmed { + t.Errorf("неверная цифра должна была отменить вооружённый чит-код") + } + if m.story.Resources[StoryResChess] != before { + t.Errorf("отменённая попытка не должна была засчитать победу, получено %d (было %d)", m.story.Resources[StoryResChess], before) + } +} + +func TestCheatCodeOnboardingAlsoAdvances(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingWithCheckers + m.story.Resources[StoryResTicTacToe] = storyStartingAmount + StoryOnboardingTicTacToeWinsNeeded + + resource := StoryResCheckers + m.storyActiveResource = &resource + m.activeGame = NewCheckersModel(0, ColorChoiceA) + m.state = statePlaying + + next, _ := m.Update(key(":")) + m = next.(AppModel) + for i := 0; i < 3; i++ { + next, _ = m.Update(key("7")) + m = next.(AppModel) + } + + if m.story.Onboarding != StoryOnboardingDone { + t.Errorf("чит-код тоже должен был продвинуть знакомство, получено %v", m.story.Onboarding) + } + if m.state != stateStoryWelcome { + t.Errorf("завершение знакомства через чит-код тоже должно было показать приветствие, получено %v", m.state) + } +} + +func TestSurvivalEndingRequiresAllSevenSpecificResources(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + + // запираем 7 ЛЮБЫХ ресурсов, но не тех самых семи — не должно сработать + m.story.Locked[StoryResNardy] = true + m.story.Locked[StoryResBlackjack] = true + m.story.Locked[StoryResUr] = true + m.story.Locked[StoryResSenet] = true + m.story.Locked[StoryResOneOhOne] = true + m.story.Locked[StoryResThousand] = true + m.story.Locked[StoryResTicTacToe] = true + if m.storySurvivalEndingUnlocked() { + t.Fatalf("секретная концовка не должна была разблокироваться от произвольных 7 ресурсов") + } + + // теперь запираем именно нужные семь + for _, id := range storySurvivalResources { + m.story.Locked[id] = true + } + if !m.storySurvivalEndingUnlocked() { + t.Errorf("секретная концовка должна была разблокироваться при запертых Тонк/Шашки/Шахматы/Сапёр/Го/Дурак/Косынка") + } +} + +func TestSurvivalOptionAppearsInHubAndLeadsToAttemptScreen(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + for _, id := range storySurvivalResources { + m.story.Locked[id] = true + } + m.state = stateStoryHub + m.storyHubCursor = len(storyHubOrder) // единственный дополнительный пункт пока + + view := m.View() + if !strings.Contains(view, T("story.hub.survival_option")) { + t.Fatalf("пункт 'Выйти из бункера' должен был появиться в хабе") + } + + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateStorySurvivalAttempt { + t.Fatalf("выбор пункта должен был вести на экран уговоров остаться, получено %v", m.state) + } +} + +func TestSurvivalAttemptStayingReturnsToHub(t *testing.T) { + m := AppModel{state: stateStorySurvivalAttempt} + next, _ := m.Update(key("1")) + m = next.(AppModel) + if m.state != stateStoryHub { + t.Errorf("выбор 'остаться' должен был вернуть в хаб, получено %v", m.state) + } +} + +func TestSurvivalAttemptLeavingGoesToEnding(t *testing.T) { + m := AppModel{state: stateStorySurvivalAttempt} + next, _ := m.Update(key("2")) + m = next.(AppModel) + if m.state != stateStoryEscapeEnding { + t.Errorf("выбор 'выйти' должен был вести на экран концовки, получено %v", m.state) + } +} + +func TestBothSecretEndingsCanAppearTogether(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + for _, id := range storySurvivalResources { + m.story.Locked[id] = true + } + m.story.Resources[StoryResTicTacToe] = 50 + m.story.Resources[StoryResSenet] = 50 + m.story.Stress = 60 + m.state = stateStoryHub + + extras := m.storyExtraHubItems() + if len(extras) != 2 { + t.Fatalf("ожидалось 2 дополнительных пункта одновременно, получено %d", len(extras)) + } + if extras[0].State != stateStorySurvivalAttempt { + t.Errorf("первым должен был идти пункт выживания, получено %v", extras[0].State) + } + if extras[1].State != stateStoryEscapeAttempt { + t.Errorf("вторым должен был идти пункт побега от отчаяния, получено %v", extras[1].State) + } + + // второй пункт (побег) должен быть доступен по курсору len(visible)+1 + m.storyHubCursor = len(storyHubOrder) + 1 + next, _ := m.Update(key("enter")) + m = next.(AppModel) + if m.state != stateStoryEscapeAttempt { + t.Errorf("второй по счёту дополнительный пункт должен был вести на экран побега, получено %v", m.state) + } +} + +func TestAllFifteenLockedTriggersTrueEnding(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + m.story.Onboarding = StoryOnboardingDone + // запираем 14 из 15 заранее — победа в оставшейся должна замкнуть круг + for i := 0; i < int(storyResourceCount); i++ { + if StoryResourceID(i) != StoryResChess { + m.story.Locked[i] = true + } + } + m.story.Resources[StoryResChess] = StoryTargetChess - 1 + + tm := NewChessModel(0, ColorChoiceA) + tm.game.Result = &ChessResult{Winner: tm.human} + tm.backToMenu = true + resource := StoryResChess + m.storyActiveResource = &resource + m.activeGame = tm + m.state = statePlaying + + next, _ := m.Update(key("q")) + m = next.(AppModel) + if m.state != stateStoryTrueEnding { + t.Fatalf("после запирания последнего, 15-го ресурса ожидалась истинная концовка, получено %v", m.state) + } +} + +func TestTrueEndingDeletesSaveAndReturnsToModeSelect(t *testing.T) { + withTempConfigDir(t) + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if err := SaveStoryModeState(s); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + m := AppModel{state: stateStoryTrueEnding, story: s} + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateModeSelect { + t.Errorf("после истинной концовки ожидался переход в меню режимов, получено %v", m.state) + } + if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { + t.Errorf("сохранение должно было быть удалено после истинной концовки, получено err=%v", err) + } +} + +func TestEscapeEndingAlsoDeletesSave(t *testing.T) { + withTempConfigDir(t) + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + if err := SaveStoryModeState(s); err != nil { + t.Fatalf("неожиданная ошибка сохранения: %v", err) + } + m := AppModel{state: stateStoryEscapeEnding, story: s} + + next, _ := m.Update(key(" ")) + m = next.(AppModel) + if m.state != stateModeSelect { + t.Errorf("ожидался переход в меню режимов, получено %v", m.state) + } + if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave { + t.Errorf("сохранение должно было быть удалено (общий экран для обеих концовок 'выйти'), получено err=%v", err) + } +} + +func TestModeSelectStartsFreshAfterAnyEnding(t *testing.T) { + withTempConfigDir(t) + // симулируем, что концовка уже случилась и сохранение удалено + _ = DeleteStoryModeSave() + + m := NewAppModel() + next, _ := m.Update(key("enter")) // язык -> выбор режима + m = next.(AppModel) + next, _ = m.Update(key("down")) // курсор на "Режим истории" + m = next.(AppModel) + next, _ = m.Update(key("enter")) + m = next.(AppModel) + + if m.state != stateStoryDifficulty { + t.Errorf("после любой концовки следующий заход должен был начинаться с выбора сложности заново, получено %v", m.state) + } +} + +func TestEnterStoryHubFromModeSelectRedirectsIfAlreadyComplete(t *testing.T) { + withTempConfigDir(t) + m := NewAppModel() + m.story = NewStoryModeState(StoryDifficultyEasy, time.Now()) + for i := range m.story.Locked { + m.story.Locked[i] = true + } + + m = m.enterStoryHubFromModeSelect() + if m.state != stateStoryTrueEnding { + t.Errorf("уже полностью собранное сохранение должно было сразу вести на истинную концовку, получено %v", m.state) + } +} + +// TestStoryHubHintWrapsInsteadOfOverflowing — регрессия: длинная +// подсказка хаба (7 разных клавиш) раньше выводилась без переноса и +// просто обрезалась терминалом на узком экране вместо переноса на +// следующую строку. +func TestStoryHubHintWrapsInsteadOfOverflowing(t *testing.T) { + withTempConfigDir(t) + m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now()), termWidth: 60} + m.story.Onboarding = StoryOnboardingDone + m.state = stateStoryHub + + view := m.View() + for _, line := range strings.Split(view, "\n") { + plain := stripANSI(line) + if w := len([]rune(plain)); w > 60 { + t.Errorf("строка шириной %d превышает ширину терминала 60: %q", w, plain) + } + } } \ No newline at end of file diff --git a/story_game.go b/story_game.go index ae2d723..bab94cb 100644 --- a/story_game.go +++ b/story_game.go @@ -109,7 +109,7 @@ const ( ) // StoryOnboardingStage — этап знакомства перед тем, как открывается -// весь лагерь целиком со всеми 15 играми и видимыми числами +// весь бункер целиком со всеми 15 играми и видимыми числами // ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется // из их текущих значений), потому что суточный расход мог бы // откатить прогресс знакомства назад — переход всегда только вперёд. @@ -117,11 +117,11 @@ type StoryOnboardingStage int const ( // StoryOnboardingTicTacToeOnly — доступны только крестики-нолики, - // без чисел ресурсов и прочего антуража обычного лагеря. + // без чисел ресурсов и прочего антуража обычного бункера. StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota // StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел. StoryOnboardingWithCheckers - // StoryOnboardingDone — обычный режим, весь лагерь как обычно. + // StoryOnboardingDone — обычный режим, весь бункер как обычно. StoryOnboardingDone ) @@ -130,7 +130,7 @@ const ( const StoryOnboardingTicTacToeWinsNeeded = 3 // StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно, -// чтобы знакомство завершилось и открылся весь лагерь. +// чтобы знакомство завершилось и открылся весь бункер. const StoryOnboardingCheckersWinsNeeded = 1 // StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может @@ -161,8 +161,10 @@ type StoryModeState struct { KlondikeWinsToday int // DailyGrantsUsed — сколько раз сегодня уже произошло начисление - // ресурса за победу (см. StoryDailyGrantsLimit и AwardResource). - DailyGrantsUsed int + // КАЖДОГО ресурса за победу, по отдельности (см. + // StoryDailyGrantsLimit и AwardResource) — не один общий счётчик + // на все 15, а свой лимит на каждый ресурс. + DailyGrantsUsed [storyResourceCount]int // Стресс, отравление и содержание токсина в воздухе — проценты // от 0 до 100. @@ -253,7 +255,7 @@ func (s *StoryModeState) Tick(now time.Time) int { s.processDayEvents(rnd) } s.KlondikeWinsToday = 0 - s.DailyGrantsUsed = 0 + s.DailyGrantsUsed = [storyResourceCount]int{} s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour) return days } @@ -336,18 +338,19 @@ func (s *StoryModeState) RecordWin(id StoryResourceID) { } // AwardResource пытается начислить +1 ресурса id за победу, но -// только если дневной лимит начислений (StoryDailyGrantsLimit) ещё -// не исчерпан за сегодня. Возвращает true, если начисление +// только если дневной лимит начислений ИМЕННО ЭТОГО ресурса +// (StoryDailyGrantsLimit — свой счётчик на каждый из 15) ещё не +// исчерпан за сегодня. Возвращает true, если начисление // действительно произошло. У Косынки помимо этого лимита есть ещё // собственное правило "3 победы за один день" — вызывающий код // (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде // чем вызвать AwardResource. func (s *StoryModeState) AwardResource(id StoryResourceID) bool { - if s.DailyGrantsUsed >= StoryDailyGrantsLimit { + if s.DailyGrantsUsed[id] >= StoryDailyGrantsLimit { return false } s.RecordWin(id) - s.DailyGrantsUsed++ + s.DailyGrantsUsed[id]++ return true } diff --git a/story_game_test.go b/story_game_test.go index 6d74a10..aeaba73 100644 --- a/story_game_test.go +++ b/story_game_test.go @@ -68,15 +68,16 @@ func TestTickResetsDailyCountersAfterAFullDay(t *testing.T) { now := time.Now() s := NewStoryModeState(StoryDifficultyEasy, now) s.KlondikeWinsToday = 2 - s.DailyGrantsUsed = 3 + s.DailyGrantsUsed[StoryResChess] = 3 + s.DailyGrantsUsed[StoryResTicTacToe] = 2 s.Tick(now.Add(25 * time.Hour)) // чуть больше суток if s.KlondikeWinsToday != 0 { t.Errorf("счётчик побед Косынки за день должен был сброситься, получено %d", s.KlondikeWinsToday) } - if s.DailyGrantsUsed != 0 { - t.Errorf("счётчик суточных начислений должен был сброситься, получено %d", s.DailyGrantsUsed) + if s.DailyGrantsUsed[StoryResChess] != 0 || s.DailyGrantsUsed[StoryResTicTacToe] != 0 { + t.Errorf("счётчики суточных начислений должны были сброситься у всех ресурсов, получено Chess=%d TicTacToe=%d", s.DailyGrantsUsed[StoryResChess], s.DailyGrantsUsed[StoryResTicTacToe]) } } @@ -92,13 +93,13 @@ func TestTickDoesNothingBeforeAFullDay(t *testing.T) { func TestTickAccumulatesMultipleDaysAtOnce(t *testing.T) { now := time.Now() s := NewStoryModeState(StoryDifficultyEasy, now) - s.DailyGrantsUsed = 3 + s.DailyGrantsUsed[StoryResChess] = 3 days := s.Tick(now.Add(72 * time.Hour)) // ровно 3 суток if days != 3 { t.Errorf("ожидалось 3 учтённых суток, получено %d", days) } - if s.DailyGrantsUsed != 0 { - t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed) + if s.DailyGrantsUsed[StoryResChess] != 0 { + t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed[StoryResChess]) } } @@ -168,6 +169,22 @@ func TestAwardResourceLimitResetsAfterADay(t *testing.T) { } } +func TestAwardResourceDailyLimitIsPerResourceNotGlobal(t *testing.T) { + s := NewStoryModeState(StoryDifficultyEasy, time.Now()) + for i := 0; i < StoryDailyGrantsLimit; i++ { + if !s.AwardResource(StoryResTicTacToe) { + t.Fatalf("начисление %d крестикам-ноликам должно было пройти", i+1) + } + } + if s.AwardResource(StoryResTicTacToe) { + t.Errorf("крестики-нолики должны были исчерпать именно свой лимит") + } + // у шашек лимит совершенно свой, не должен быть затронут + if !s.AwardResource(StoryResCheckers) { + t.Errorf("исчерпанный лимит крестиков-ноликов не должен был повлиять на лимит шашек") + } +} + func TestSpendAntidepressantCanGoBelowFloor(t *testing.T) { now := time.Now() s := NewStoryModeState(StoryDifficultyEasy, now) diff --git a/story_tui.go b/story_tui.go index 8436a2d..9a0fa41 100644 --- a/story_tui.go +++ b/story_tui.go @@ -466,7 +466,7 @@ func (m AppModel) viewStoryDifficulty() string { fmt.Fprintf(&b, "%s%s\n", cursor, label) } fmt.Fprintln(&b) - fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth))) return b.String() } @@ -480,6 +480,47 @@ func (m AppModel) storyEscapeUnlocked() bool { return enoughSoap && enoughRope && highStress } +// storySurvivalResources — семь конкретных ресурсов, нужных для +// "спокойной" секретной концовки: реально необходимые для жизни +// снаружи вещи, а не любые 7 из 15. +var storySurvivalResources = [7]StoryResourceID{ + StoryResTonk, StoryResCheckers, StoryResChess, StoryResMinesweeper, + StoryResGo, StoryResDurak, StoryResKlondike, +} + +// storySurvivalEndingUnlocked проверяет, заперты ли в сейф все семь +// ресурсов выживания разом. +func (m AppModel) storySurvivalEndingUnlocked() bool { + for _, id := range storySurvivalResources { + if !m.story.Locked[id] { + return false + } + } + return true +} + +// storyExtraHubItem — один дополнительный пункт в конце списка игр +// хаба (скрытая концовка), доступный только при выполнении своего +// условия. +type storyExtraHubItem struct { + LabelKey string + State appState +} + +// storyExtraHubItems возвращает дополнительные пункты хаба, доступные +// прямо сейчас — порядок стабильный: сначала "спокойная" концовка +// (собраны вещи для жизни снаружи), потом побег от отчаяния. +func (m AppModel) storyExtraHubItems() []storyExtraHubItem { + var items []storyExtraHubItem + if m.storySurvivalEndingUnlocked() { + items = append(items, storyExtraHubItem{LabelKey: "story.hub.survival_option", State: stateStorySurvivalAttempt}) + } + if m.storyEscapeUnlocked() { + items = append(items, storyExtraHubItem{LabelKey: "story.hub.escape_option", State: stateStoryEscapeAttempt}) + } + return items +} + // storyVisibleGames возвращает список игр, доступных прямо сейчас — // на этапе знакомства это укороченный список (только крестики-нолики, // затем плюс шашки), после его завершения — обычный полный список. @@ -518,10 +559,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) { } visible := m.storyVisibleGames() onboarding := m.story.Onboarding != StoryOnboardingDone - maxCursor := len(visible) - 1 - if !onboarding && m.storyEscapeUnlocked() { - maxCursor++ // дополнительный пункт "Сбежать из бункера" в конце списка + var extras []storyExtraHubItem + if !onboarding { + extras = m.storyExtraHubItems() } + maxCursor := len(visible) + len(extras) - 1 switch keyMsg.String() { case "ctrl+c": m.quitting = true @@ -553,11 +595,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = stateStoryUseItem return m, nil case "enter": - if !onboarding && m.storyEscapeUnlocked() && m.storyHubCursor == len(visible) { - m.state = stateStoryEscapeAttempt - return m, nil - } if m.storyHubCursor >= len(visible) { + idx := m.storyHubCursor - len(visible) + if idx >= 0 && idx < len(extras) { + m.state = extras[idx].State + } return m, nil } id := visible[m.storyHubCursor] @@ -588,6 +630,10 @@ func (m AppModel) viewStoryHub() string { onboarding := m.story.Onboarding != StoryOnboardingDone visible := m.storyVisibleGames() + var extras []storyExtraHubItem + if !onboarding { + extras = m.storyExtraHubItems() + } if !onboarding { fmt.Fprintln(&b, dimStyle.Render(T("story.hub.difficulty_label")+" "+T(storyDifficultyLabelKey(m.story.Difficulty)))) @@ -604,7 +650,7 @@ func (m AppModel) viewStoryHub() string { var line string if onboarding { // на этапе знакомства — только название игры, без чисел - // ресурсов и прочего антуража обычного лагеря; и именно + // ресурсов и прочего антуража обычного бункера; и именно // название ИГРЫ, а не ресурса — до объяснения от ИИ // "кусок мыла"/"банка тушёнки" ничего не значат для игрока line = T(storyGameDefs[id].NameKey) @@ -629,24 +675,22 @@ func (m AppModel) viewStoryHub() string { fmt.Fprintf(&b, "%s%s\n", cursor, line) } - if !onboarding && m.storyEscapeUnlocked() { - cursor := " " - line := errorStyle.Render(T("story.hub.escape_option")) - if m.storyHubCursor == len(visible) { - cursor = menuCursorStyle.Render("> ") + if !onboarding { + for i, item := range extras { + cursor := " " + line := errorStyle.Render(T(item.LabelKey)) + if m.storyHubCursor == len(visible)+i { + cursor = menuCursorStyle.Render("> ") + } + fmt.Fprintf(&b, "%s%s\n", cursor, line) } - fmt.Fprintf(&b, "%s%s\n", cursor, line) } fmt.Fprintln(&b) - if !onboarding && m.story.Complete() { - fmt.Fprintln(&b, winStyle.Render(T("story.hub.complete"))) - fmt.Fprintln(&b) - } if onboarding { - fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint.onboarding"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint.onboarding"), m.termWidth))) } else { - fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint"))) + fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint"), m.termWidth))) } return b.String() } @@ -689,7 +733,7 @@ func (k storyGreetingKind) textKey() string { } // storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ -// убежища, одна из которых случайно показывается в лагере после +// убежища, одна из которых случайно показывается в бункере после // возврата из только что сыгранной партии — заметки о // физиологической реакции игрока по показаниям датчиков. var storyPostGameFlavorKeys = []string{ @@ -720,6 +764,10 @@ func (m AppModel) enterStoryHubFromModeSelect() AppModel { m.story.ApplyAirPurification(time.Now()) m.story.ApplyStressRecovery(time.Now()) _ = SaveStoryModeState(m.story) + if m.story.Complete() { + m.state = stateStoryTrueEnding + return m + } if m.story.Onboarding != StoryOnboardingDone { // на этапе знакомства ИИ убежища ещё не представилась игроку // (это происходит только в момент завершения знакомства, см. @@ -744,6 +792,10 @@ func (m AppModel) enterStoryHub() AppModel { m.story.ApplyAirPurification(time.Now()) m.story.ApplyStressRecovery(time.Now()) _ = SaveStoryModeState(m.story) + if m.story.Complete() { + m.state = stateStoryTrueEnding + return m + } m.state = stateStoryHub return m } @@ -900,7 +952,7 @@ func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.storyWelcomeWordsShown < total { // текст ещё печатается — любая клавиша сразу показывает - // его целиком, а не продолжает в лагерь + // его целиком, а не продолжает в бункер m.storyWelcomeWordsShown = total return m, nil } @@ -1025,6 +1077,34 @@ func (m AppModel) viewStoryUseItem() string { return b.String() } +func (m AppModel) updateStorySurvivalAttempt(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "1", "esc", "q": + m.state = stateStoryHub + case "2": + m.state = stateStoryEscapeEnding + } + return m, nil +} + +func (m AppModel) viewStorySurvivalAttempt() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.survival.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T("story.survival.text"), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, T("story.survival.stay")) + fmt.Fprintln(&b, errorStyle.Render(T("story.survival.leave"))) + return b.String() +} + func (m AppModel) updateStoryEscapeAttempt(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { @@ -1070,10 +1150,39 @@ func (m AppModel) updateStoryEscapeEnding(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } + // любая концовка — следующий заход в сторимод должен начинаться + // с самого начала, а не продолжать прежний прогресс + _ = DeleteStoryModeSave() + m.story = nil m.state = stateModeSelect return m, nil } +func (m AppModel) updateStoryTrueEnding(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if keyMsg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + _ = DeleteStoryModeSave() + m.story = nil + m.state = stateModeSelect + return m, nil +} + +func (m AppModel) viewStoryTrueEnding() string { + var b strings.Builder + fmt.Fprintln(&b, menuTitleStyle.Render(T("story.trueending.title"))) + fmt.Fprintln(&b) + fmt.Fprintln(&b, wrapText(T("story.trueending.text"), m.termWidth)) + fmt.Fprintln(&b) + fmt.Fprintln(&b, menuHintStyle.Render(T("story.trueending.hint"))) + return b.String() +} + func (m AppModel) viewStoryEscapeEnding() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title"))) diff --git a/translations.go b/translations.go index de871ff..2017947 100644 --- a/translations.go +++ b/translations.go @@ -20,11 +20,13 @@ var translations = map[string]translationEntry{ "story.difficulty.medium": {"Средняя", "Medium"}, "story.difficulty.hard": {"Сложная", "Hard"}, - "story.hub.title": {"=== ЛАГЕРЬ ===", "=== CAMP ==="}, + "story.hub.title": {"=== БУНКЕР ===", "=== BUNKER ==="}, "story.hub.difficulty_label": {"Сложность:", "Difficulty:"}, "story.hub.locked": {"в сейфе", "in the safe"}, "story.hub.notyet": {"(пока недоступно)", "(not available yet)"}, - "story.hub.complete": {"Все запасы собраны — история пройдена!", "All supplies gathered — the story is complete!"}, + "story.trueending.title": {"=== КОНЕЦ ===", "=== THE END ==="}, + "story.trueending.text": {"Ты молодец! Смог собрать все ресурсы и показал мне на что способен человек, если им немного понукать, хвалить и корректировать настроение с помощью лекарств. Тебе было плохо, у тебя возникали странные мысли, но ты добрался до цели и дал мне много новых данных для дальнейшей работы. Спасибо тебе и прощай!", "Well done! You managed to gather all the resources and showed me what a person is capable of, given a little prodding, praise, and mood correction with medication. You felt awful, you had strange thoughts, but you reached the goal and gave me a lot of new data to work with. Thank you, and farewell!"}, + "story.trueending.hint": {"[любая клавиша] в меню режимов", "[any key] back to mode select"}, "story.hub.hint": {"[↑↓/kj] выбор игры [enter] играть [c] условия [u] использовать [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [c] conditions [u] use item [N] new game [esc/q] back to mode select [Q] quit game"}, "story.hub.vitals": {"Стресс: %d%% Отравление: %d%% Токсин в воздухе: %d%%", "Stress: %d%% Poisoning: %d%% Toxin in air: %d%%"}, "story.hub.reset_confirm": {"Начать сторимод заново? Весь прогресс будет потерян безвозвратно. [y] да / [n] нет", "Start story mode over? All progress will be lost permanently. [y] yes / [n] no"}, @@ -35,8 +37,9 @@ var translations = map[string]translationEntry{ "story.useitem.antidote": {"снизить отравление на 20%", "reduce poisoning by 20%"}, "story.useitem.airfilter": {"отключить рост токсина до следующего события", "pause toxin growth until the next event"}, "story.useitem.battery": {"запустить очистку воздуха (токсин к 0 за час)", "start air purification (toxin to 0 over an hour)"}, - "story.useitem.hint": {"[1-4] использовать [esc/q] в лагерь", "[1-4] use [esc/q] back to camp"}, + "story.useitem.hint": {"[1-4] использовать [esc/q] в бункер", "[1-4] use [esc/q] back to bunker"}, "story.hub.hint.onboarding": {"[↑↓/kj] выбор игры [enter] играть [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [N] new game [esc/q] back to mode select [Q] quit game"}, + "story.hub.survival_option": {"Выйти из бункера", "Leave the bunker"}, "story.hub.escape_option": {"Сбежать из бункера", "Escape the bunker"}, "story.welcome.title": {"=== ПРИВЕТ ===", "=== HELLO ==="}, @@ -45,7 +48,7 @@ var translations = map[string]translationEntry{ "story.welcome.hint.typing": {"[любая клавиша] показать сразу", "[any key] show instantly"}, "story.conditions.title": {"=== УСЛОВИЯ НАЧИСЛЕНИЯ ===", "=== EARNING CONDITIONS ==="}, - "story.conditions.hint": {"[любая клавиша] назад в лагерь", "[any key] back to camp"}, + "story.conditions.hint": {"[любая клавиша] назад в бункер", "[any key] back to bunker"}, "story.condition.win": {"Победа", "Win"}, "story.condition.tonk": {"3+ раунда; баланс не ниже, чем у соперников, при выходе", "3+ rounds; balance no lower than opponents' when you leave"}, "story.condition.durak": {"3 партии подряд без 'дурака'", "3 games in a row without being the fool"}, @@ -57,6 +60,11 @@ var translations = map[string]translationEntry{ "story.condition.tictactoe": {"Победа или ничья", "Win or draw"}, "story.condition.poker": {"Победа во всём турнире", "Win the whole tournament"}, + "story.survival.title": {"=== ГОЛОС ИЗ ДИНАМИКА ===", "=== A VOICE FROM THE SPEAKER ==="}, + "story.survival.text": {"Поздравляю! Ты собрал необходимые вещи для выживания снаружи и можешь выйти.\n\nНо я бы хотела, чтобы ты остался. Я очень за тебя переживаю и хочу, чтобы ты лучше подготовился.", "Congratulations! You've gathered the supplies you need to survive out there, and you can leave.\n\nBut I'd like you to stay. I care about you so much, and I want you to be even better prepared."}, + "story.survival.stay": {"[1] Остаться", "[1] Stay"}, + "story.survival.leave": {"[2] Выйти из бункера", "[2] Leave the bunker"}, + "story.escape.title": {"=== ГОЛОС ИЗ ДИНАМИКА ===", "=== A VOICE FROM THE SPEAKER ==="}, "story.escape.text": {"Не делай этого, ты же знаешь, что это не выход!\n\nНужно немного потерпеть и всё станет хорошо!\n\nМы сможем найти выход из этой ситуации, у тебя всё так хорошо получается!\n\nВот, в лотке выдачи застряла таблетка успокоительного, выпей её и отбрось эти мысли.", "Don't do this, you know that's not the answer!\n\nYou just need to hold on a little longer and everything will be fine!\n\nWe can still find a way out of this, you're doing so well!\n\nHere, there's a sedative pill stuck in the dispenser tray — take it and let go of these thoughts."}, "story.escape.stay": {"[1] Выпить таблетку", "[1] Take the pill"}, @@ -721,4 +729,4 @@ func init() { } { translations[k] = v } -} \ No newline at end of file +}