Fixed story mod

This commit is contained in:
Magnus Root 2026-07-16 09:53:25 +03:00
parent 79e91784d2
commit 3f90ba3220
15 changed files with 874 additions and 111 deletions

View file

@ -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 {
m.cursor.Row--
}
case "down", "j":
if flip {
if m.cursor.Row < 7 {
m.cursor.Row++
}
} else if m.cursor.Row > 0 {
m.cursor.Row--
}
case "down", "j":
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 {

View file

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

View file

@ -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 {
m.cursor.Row--
}
case "down", "j":
if flip {
if m.cursor.Row < 7 {
m.cursor.Row++
}
} else if m.cursor.Row > 0 {
m.cursor.Row--
}
case "down", "j":
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 {

View file

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

BIN
ggc (Stored with Git LFS)

Binary file not shown.

BIN
ggc.exe (Stored with Git LFS)

Binary file not shown.

133
menu.go
View file

@ -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,13 +1622,31 @@ 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 {
finished, w := def.Outcome(m.activeGame)
won = finished && w
}
return m.storyProcessGameOutcome(id, won)
}
m.state = stateMenu
m.activeGame = nil
return m, nil
}
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)
// проверяется уже поверх этого правила
// у Косынки особое правило — 3 победы за один день, а не
// начисление на каждую; суточный лимит начислений
// (AwardResource) проверяется уже поверх этого правила
m.story.KlondikeWinsToday++
if m.story.KlondikeWinsToday == 3 {
m.story.AwardResource(id)
@ -1626,27 +1655,73 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
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
}
m.state = stateMenu
m.activeGame = nil
return 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
}
return m, cmd
}
func (m AppModel) View() string {
@ -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()
}

View file

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

View file

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

View file

@ -1223,3 +1223,368 @@ func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) {
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)
}
}
}

View file

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

View file

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

View file

@ -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() {
if !onboarding {
for i, item := range extras {
cursor := " "
line := errorStyle.Render(T("story.hub.escape_option"))
if m.storyHubCursor == len(visible) {
line := errorStyle.Render(T(item.LabelKey))
if m.storyHubCursor == len(visible)+i {
cursor = menuCursorStyle.Render("> ")
}
fmt.Fprintf(&b, "%s%s\n", cursor, line)
}
}
fmt.Fprintln(&b)
if !onboarding && m.story.Complete() {
fmt.Fprintln(&b, winStyle.Render(T("story.hub.complete")))
fmt.Fprintln(&b)
}
if onboarding {
fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint.onboarding")))
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")))

View file

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