package main import ( "fmt" "math/rand" "strings" "time" tea "github.com/charmbracelet/bubbletea" ) // --- Соответствие сложности сторимода уровню сложности конкретной // игры. Двухступенчатые игры (Шашки, Шахматы) получают "простой" на // лёгком и среднем сторимода и "сильный" только на сложном; // трёхступенчатые — соответствие один-в-один. func storyDifficultyToTicTacToe(d StoryDifficulty) TicTacToeDifficulty { switch d { case StoryDifficultyEasy: return TicTacToeDifficultyEasy case StoryDifficultyMedium: return TicTacToeDifficultyMedium default: return TicTacToeDifficultyHard } } func storyDifficultyToCheckersDepth(d StoryDifficulty) int { if d == StoryDifficultyHard { return CheckersDifficultyHard } return CheckersDifficultyEasy } func storyDifficultyToChessDepth(d StoryDifficulty) int { if d == StoryDifficultyHard { return ChessDifficultyHard } return ChessDifficultyEasy } func storyDifficultyToGo(d StoryDifficulty) GoDifficulty { switch d { case StoryDifficultyEasy: return GoDifficultyEasy case StoryDifficultyMedium: return GoDifficultyMedium default: return GoDifficultyHard } } func storyDifficultyToUr(d StoryDifficulty) UrDifficulty { switch d { case StoryDifficultyEasy: return UrDifficultyEasy case StoryDifficultyMedium: return UrDifficultyMedium default: return UrDifficultyHard } } func storyDifficultyToSenet(d StoryDifficulty) SenetDifficulty { switch d { case StoryDifficultyEasy: return SenetDifficultyEasy case StoryDifficultyMedium: return SenetDifficultyMedium default: return SenetDifficultyHard } } func storyDifficultyToNardy(d StoryDifficulty) NardyDifficulty { switch d { case StoryDifficultyEasy: return NardyDifficultyEasy case StoryDifficultyMedium: return NardyDifficultyMedium default: return NardyDifficultyHard } } // --- Определение исхода партии для каждой подключённой игры: партия // завершилась и (если да) выиграл ли человек. Возвращает (false, // false), если партия ещё идёт или тип модели не тот, что ожидался. func storyOutcomeTicTacToe(m tea.Model) (finished, won bool) { tm, ok := m.(TicTacToeModel) if !ok || tm.game.Result == nil { return false, false } winner := tm.game.Result.Winner // в сторимоде ничья приравнена к победе — партия не проиграна, // только если противник выиграл явно return true, winner == tm.game.HumanMark || winner == TicTacToeEmpty } func storyOutcomeCheckers(m tea.Model) (finished, won bool) { tm, ok := m.(CheckersModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.human } func storyOutcomeChess(m tea.Model) (finished, won bool) { tm, ok := m.(ChessModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.human } func storyOutcomeNardy(m tea.Model) (finished, won bool) { tm, ok := m.(NardyModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.humanColor } func storyOutcomeGo(m tea.Model) (finished, won bool) { tm, ok := m.(GoModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.human } func storyOutcomeUr(m tea.Model) (finished, won bool) { tm, ok := m.(UrModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.humanColor } func storyOutcomeSenet(m tea.Model) (finished, won bool) { tm, ok := m.(SenetModel) if !ok || tm.game.Result == nil { return false, false } return true, tm.game.Result.Winner == tm.humanColor } // storyOutcomeBlackjack — правило для карточных игр на деньги: // партия засчитывается только если сыграно не меньше трёх раундов и // баланс человека в итоге выше стартового капитала. Если раундов // меньше трёх (сдался раньше) или баланс не вырос — засчитывается // как незавершённая попытка, ресурс не начисляется. func storyOutcomeBlackjack(m tea.Model) (finished, won bool) { bm, ok := m.(BlackjackModel) if !ok || bm.game.Phase != BJPhaseRoundOver { return false, false } human := bm.game.Players[bm.humanIdx] won = bm.game.RoundsPlayed >= 3 && human.Balance > bm.game.StartingCapital return true, won } // storyOutcomeTonk — тот же принцип, что и в Блэкджеке: минимум три // раунда сыграно, и на момент выхода баланс человека не ниже, чем у // любого другого игрока за столом (ничья за первое место тоже // засчитывается). func storyOutcomeTonk(m tea.Model) (finished, won bool) { tm, ok := m.(Model) if !ok || tm.game.Phase != PhaseGameOver { return false, false } if tm.game.RoundsPlayed < 3 { return true, false } human := tm.game.Players[tm.humanIdx] maxBalance := human.Balance for _, p := range tm.game.Players { if p.Balance > maxBalance { maxBalance = p.Balance } } return true, human.Balance == maxBalance } // storyOutcomeDurak — нужно 3 подряд партии в этой сессии без // поражения (не быть дураком). storyNonFoolStreak хранит серию ДО // текущей партии; здесь отдельно учитывается исход именно этой, // последней партии — иначе выход сразу после решающей победы (не // через "n") потерял бы её из серии. func storyOutcomeDurak(m tea.Model) (finished, won bool) { dm, ok := m.(DurakModel) if !ok || dm.game.Result == nil { return false, false } wasFool := !dm.game.Result.NoLoser && !dm.game.Result.Stalemate && dm.game.Result.Loser == dm.humanIdx effectiveStreak := dm.storyNonFoolStreak + 1 if wasFool { effectiveStreak = 0 } return true, effectiveStreak >= 3 } // storyOutcomeMinesweeper — нужно 3 подряд партии в этой сессии без // взрыва. Как и в Дураке, последняя (текущая) партия учитывается // отдельно от уже накопленной серии — иначе выход сразу после // решающей победы потерял бы её из счёта. func storyOutcomeMinesweeper(m tea.Model) (finished, won bool) { mm, ok := m.(MinesweeperModel) if !ok || mm.game.Result == nil { return false, false } effectiveStreak := mm.storyWinStreak if mm.game.Result.Won { effectiveStreak++ } else { effectiveStreak = 0 } return true, effectiveStreak >= 3 } // storyOutcomeOneOhOne — простая победа, но во всём МАТЧЕ (в 101 // счёт идёт через несколько раундов, отдельный раунд ничего не // решает), а не в одном отдельном раунде. func storyOutcomeOneOhOne(m tea.Model) (finished, won bool) { om, ok := m.(OneOhOneModel) if !ok || om.game.Phase != OneOhOnePhaseRoundOver || om.game.Result == nil { return false, false } if !om.game.Result.MatchOver { return false, false // матч ещё продолжается — рано подводить итог } return true, !om.game.Result.NoWinner && om.game.Result.MatchWinner == om.humanIdx } // storyOutcomeThousand — тот же принцип, что и в Блэкджеке/Тонке: // минимум 3 раздачи сыграно, и на момент выхода счёт человека не // ниже, чем у любого другого игрока (ничья за первое место тоже // засчитывается). func storyOutcomeThousand(m tea.Model) (finished, won bool) { tm, ok := m.(ThousandModel) if !ok || tm.game.Phase != ThousandPhaseHandOver { return false, false } if tm.game.HandNumber < 3 { return true, false } human := tm.game.Players[tm.humanIdx] maxScore := human.Score for _, p := range tm.game.Players { if p.Score > maxScore { maxScore = p.Score } } return true, human.Score == maxScore } // storyOutcomeKlondike сообщает только "выиграна ли партия" — сам // подсчёт "3 победы за день" происходит отдельно, на уровне // диспетчера в menu.go (см. KlondikeWinsToday), а не здесь. func storyOutcomeKlondike(m tea.Model) (finished, won bool) { km, ok := m.(KlondikeModel) if !ok || !km.game.Won { return false, false } return true, true } // storyOutcomePoker — выигрыш ВСЕГО турнира (последний оставшийся // игрок с фишками), а не отдельной раздачи. Сложность сторимода на // покер сейчас не влияет — уровень каждого бота и так назначается // случайно независимо от общей сложности (см. NewPokerModel). func storyOutcomePoker(m tea.Model) (finished, won bool) { pm, ok := m.(PokerModel) if !ok || pm.game.Phase != PokerPhaseTournamentOver { return false, false } return true, !pm.game.Players[pm.humanSeat].Eliminated } // storyGameDef — описание одной игры, доступной в сторимоде: как её // запустить (уже с фиксированными настройками и сложностью // сторимода вместо обычного экрана настройки) и как определить, // выиграл ли человек, когда партия завершилась. type storyGameDef struct { NameKey string Launch func(difficulty StoryDifficulty) tea.Model Outcome func(m tea.Model) (finished, won bool) } // storyGameDefs — только подключённые на сегодня игры (чистое // условие победы: Result != nil, сравнение Winner с самим собой). // Игры со составной семантикой исхода партии (Дурак, Блэкджек, 101, // 1000, Косынка, Сапёр, Покер) требуют отдельного решения по // каждой и будут подключены следующим шагом — намеренно не // угадываю их условие победы второпях. var storyGameDefs = map[StoryResourceID]storyGameDef{ StoryResTicTacToe: { NameKey: "game.tictactoe.name", Launch: func(d StoryDifficulty) tea.Model { return NewTicTacToeModel(storyDifficultyToTicTacToe(d)) }, Outcome: storyOutcomeTicTacToe, }, StoryResCheckers: { NameKey: "game.checkers.name", Launch: func(d StoryDifficulty) tea.Model { return NewCheckersModel(storyDifficultyToCheckersDepth(d), ColorChoiceRandom) }, Outcome: storyOutcomeCheckers, }, StoryResChess: { NameKey: "game.chess.name", Launch: func(d StoryDifficulty) tea.Model { return NewChessModel(storyDifficultyToChessDepth(d), ColorChoiceRandom) }, Outcome: storyOutcomeChess, }, StoryResNardy: { NameKey: "game.nardy.name", Launch: func(d StoryDifficulty) tea.Model { return NewNardyModel(NardyShort, storyDifficultyToNardy(d), ColorChoiceRandom) }, Outcome: storyOutcomeNardy, }, StoryResGo: { NameKey: "game.go.name", Launch: func(d StoryDifficulty) tea.Model { return NewGoModel(9, storyDifficultyToGo(d), ColorChoiceRandom) }, Outcome: storyOutcomeGo, }, StoryResUr: { NameKey: "game.ur.name", Launch: func(d StoryDifficulty) tea.Model { return NewUrModel(storyDifficultyToUr(d), ColorChoiceRandom) }, Outcome: storyOutcomeUr, }, StoryResSenet: { NameKey: "game.senet.name", Launch: func(d StoryDifficulty) tea.Model { return NewSenetModel(storyDifficultyToSenet(d), ColorChoiceRandom) }, Outcome: storyOutcomeSenet, }, StoryResBlackjack: { NameKey: "game.blackjack.name", Launch: func(d StoryDifficulty) tea.Model { // сложность сторимода на Блэкджек не влияет — у дилера // нет уровня "бота", он всегда играет по фиксированным // правилам казино; человек играет один на один с дилером return NewBlackjackModel(1, 500, 25) }, Outcome: storyOutcomeBlackjack, }, StoryResTonk: { NameKey: "game.tonk.name", Launch: func(d StoryDifficulty) tea.Model { // у ботов Тонка нет уровня сложности — фиксированные стратегии return NewModel() }, Outcome: storyOutcomeTonk, }, StoryResDurak: { NameKey: "game.durak.name", Launch: func(d StoryDifficulty) tea.Model { // у бота Дурака тоже нет уровня сложности return NewDurakModel(4) }, Outcome: storyOutcomeDurak, }, StoryResMinesweeper: { NameKey: "game.minesweeper.name", Launch: func(d StoryDifficulty) tea.Model { // Сапёр без противника — сложность сторимода не при делах; // берём "новичок" (9x9, 10 мин) — задача про серию из 3 // побед и так достаточно требовательна return NewMinesweeperModel(9, 9, 10) }, Outcome: storyOutcomeMinesweeper, }, StoryResOneOhOne: { NameKey: "game.oneohone.name", Launch: func(d StoryDifficulty) tea.Model { return NewOneOhOneModel(3) }, Outcome: storyOutcomeOneOhOne, }, StoryResThousand: { NameKey: "game.thousand.name", Launch: func(d StoryDifficulty) tea.Model { // 1000 поддерживает только троих игроков (правила игры) return NewThousandModel() }, Outcome: storyOutcomeThousand, }, StoryResKlondike: { NameKey: "game.klondike.name", Launch: func(d StoryDifficulty) tea.Model { // Косынка — головоломка без противника, сложность не при делах return NewKlondikeModel() }, Outcome: storyOutcomeKlondike, }, StoryResPoker: { NameKey: "game.poker.name", Launch: func(d StoryDifficulty) tea.Model { return NewPokerModel(4) }, Outcome: storyOutcomePoker, }, } // storyHubOrder — порядок показа ресурсов в хабе (стабильный, // отдельный от порядка iota, чтобы можно было перегруппировать // показ, не трогая сами значения StoryResourceID). var storyHubOrder = []StoryResourceID{ StoryResTonk, StoryResDurak, StoryResCheckers, StoryResNardy, StoryResBlackjack, StoryResChess, StoryResMinesweeper, StoryResGo, StoryResUr, StoryResSenet, StoryResOneOhOne, StoryResThousand, StoryResKlondike, StoryResTicTacToe, StoryResPoker, } func (m AppModel) updateStoryDifficulty(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { return m, nil } switch keyMsg.String() { case "ctrl+c": m.quitting = true return m, tea.Quit case "esc", "q": m.state = stateModeSelect return m, nil case "up", "k": if m.storyDifficultyCursor > 0 { m.storyDifficultyCursor-- } case "down", "j": if m.storyDifficultyCursor < 2 { m.storyDifficultyCursor++ } case "enter": m.story = NewStoryModeState(StoryDifficulty(m.storyDifficultyCursor), time.Now()) _ = SaveStoryModeState(m.story) m.state = stateStoryHub } return m, nil } func (m AppModel) viewStoryDifficulty() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.difficulty.title"))) fmt.Fprintln(&b) options := []string{T("story.difficulty.easy"), T("story.difficulty.medium"), T("story.difficulty.hard")} for i, opt := range options { cursor := " " label := menuItemStyle.Render(opt) if i == m.storyDifficultyCursor { cursor = menuCursorStyle.Render("> ") label = menuCursorStyle.Render(opt) } fmt.Fprintf(&b, "%s%s\n", cursor, label) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth))) return b.String() } // storyEscapeUnlocked проверяет условие скрытой концовки — все три // разом: 50 кусков мыла (Крестики-нолики), 50 верёвок (Сенет) и // стресс 60% и выше. func (m AppModel) storyEscapeUnlocked() bool { enoughSoap := m.story.Resources[StoryResTicTacToe] >= 50 enoughRope := m.story.Resources[StoryResSenet] >= 50 highStress := m.story.Stress >= 60 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 возвращает список игр, доступных прямо сейчас — // на этапе знакомства это укороченный список (только крестики-нолики, // затем плюс шашки), после его завершения — обычный полный список. func (m AppModel) storyVisibleGames() []StoryResourceID { switch m.story.Onboarding { case StoryOnboardingTicTacToeOnly: return []StoryResourceID{StoryResTicTacToe} case StoryOnboardingWithCheckers: return []StoryResourceID{StoryResTicTacToe, StoryResCheckers} default: return storyHubOrder } } func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { return m, nil } if m.storyConfirmingReset { switch keyMsg.String() { case "ctrl+c": m.quitting = true return m, tea.Quit case "y", "Y": _ = DeleteStoryModeSave() m.story = nil m.storyConfirmingReset = false m.storyPostGameFlavor = "" m.storyHubCursor = 0 m.state = stateStoryDifficulty case "n", "N", "esc": m.storyConfirmingReset = false } return m, nil } visible := m.storyVisibleGames() onboarding := m.story.Onboarding != StoryOnboardingDone var extras []storyExtraHubItem if !onboarding { extras = m.storyExtraHubItems() } maxCursor := len(visible) + len(extras) - 1 switch keyMsg.String() { case "ctrl+c": m.quitting = true return m, tea.Quit case "N": m.storyConfirmingReset = true return m, nil case "esc", "q": m.state = stateModeSelect return m, nil case "up", "k": if m.storyHubCursor > 0 { m.storyHubCursor-- } case "down", "j": if m.storyHubCursor < maxCursor { m.storyHubCursor++ } case "c": if onboarding { return m, nil } m.state = stateStoryConditions return m, nil case "u": if onboarding { return m, nil } m.state = stateStoryUseItem return m, nil case "enter": 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] def, playable := storyGameDefs[id] if !playable { return m, nil } resource := id m.storyActiveResource = &resource m.activeGame = def.Launch(m.story.Difficulty) m.state = statePlaying return m, m.activeGame.Init() } return m, nil } func (m AppModel) viewStoryHub() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.hub.title"))) fmt.Fprintln(&b) if m.storyConfirmingReset { fmt.Fprintln(&b, errorStyle.Render(T("story.hub.reset_confirm"))) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.reset_confirm_hint"))) return b.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)))) fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel)) if m.storyPostGameFlavor != "" { fmt.Fprintln(&b, infoStyle.Render(T(m.storyPostGameFlavor))) } fmt.Fprintln(&b) } for i, id := range visible { resDef := storyResourceDefs[id] cursor := " " var line string if onboarding { // на этапе знакомства — только название игры, без чисел // ресурсов и прочего антуража обычного бункера; и именно // название ИГРЫ, а не ресурса — до объяснения от ИИ // "кусок мыла"/"банка тушёнки" ничего не значат для игрока line = T(storyGameDefs[id].NameKey) } else { _, playable := storyGameDefs[id] status := fmt.Sprintf("%d/%d", m.story.Resources[id], resDef.Target) if m.story.Locked[id] { status = T("story.hub.locked") } line = fmt.Sprintf("%-24s %s", T(resDef.NameKey), status) if !playable { line += " " + dimStyle.Render(T("story.hub.notyet")) } if !playable && i != m.storyHubCursor { line = dimStyle.Render(line) } } if i == m.storyHubCursor { cursor = menuCursorStyle.Render("> ") line = menuCursorStyle.Render(line) } fmt.Fprintf(&b, "%s%s\n", cursor, line) } 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.Fprintln(&b) if onboarding { fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint.onboarding"), m.termWidth))) } else { fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint"), m.termWidth))) } return b.String() } // storyGreetingKind — какое из трёх приветствий дня показать при // входе в сторимод именно через меню режимов (выбор "Режим // истории") — это про начало новой сессии, а не про возврат из // только что сыгранной партии внутри уже идущей сессии. type storyGreetingKind int const ( storyGreetingNone storyGreetingKind = iota storyGreetingWokeUp // прошёл ровно один день storyGreetingPlayingAgain // 0 дней — снова зашёл в тот же день storyGreetingWorried // 2 и более дня ) func storyGreetingForDays(days int) storyGreetingKind { switch { case days == 0: return storyGreetingPlayingAgain case days == 1: return storyGreetingWokeUp default: return storyGreetingWorried } } func (k storyGreetingKind) textKey() string { switch k { case storyGreetingWokeUp: return "story.morning.text.wokeup" case storyGreetingPlayingAgain: return "story.morning.text.again" case storyGreetingWorried: return "story.morning.text.worried" default: return "story.morning.text.wokeup" } } // storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ // убежища, одна из которых случайно показывается в бункере после // возврата из только что сыгранной партии — заметки о // физиологической реакции игрока по показаниям датчиков. var storyPostGameFlavorKeys = []string{ "story.postgame.flavor1", "story.postgame.flavor2", "story.postgame.flavor3", "story.postgame.flavor4", "story.postgame.flavor5", "story.postgame.flavor6", "story.postgame.flavor7", "story.postgame.flavor8", "story.postgame.flavor9", "story.postgame.flavor10", "story.postgame.flavor11", "story.postgame.flavor12", } func randomStoryPostGameFlavor() string { return storyPostGameFlavorKeys[rand.Intn(len(storyPostGameFlavorKeys))] } // enterStoryHubFromModeSelect применяет накопившийся суточный расход // (и очистку воздуха/восстановление стресса) и показывает одно из // трёх приветствий дня, в зависимости от того, сколько суток прошло // с последнего входа именно через меню режимов. func (m AppModel) enterStoryHubFromModeSelect() AppModel { days := m.story.Tick(time.Now()) 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 { // на этапе знакомства ИИ убежища ещё не представилась игроку // (это происходит только в момент завершения знакомства, см. // AdvanceOnboarding) — рано показывать её приветствия дня и // суточные события; они тихо копятся (Tick выше их всё равно // обсчитал) и всплывут при первом же входе после завершения m.state = stateStoryHub return m } m.storyGreeting = storyGreetingForDays(days) m.state = stateStoryMorning return m } // enterStoryHub используется при возврате из только что сыгранной // партии: применяет тот же накопившийся расход и очистку/восстановление // молча, без приветствия дня (это продолжение уже идущей сессии, а // не новый вход) — но показывает случайную короткую фразу-наблюдение // поверх обычного хаба (см. storyPostGameFlavorKeys). func (m AppModel) enterStoryHub() AppModel { m.story.Tick(time.Now()) 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 } // advanceStoryEventQueue решает, куда вести дальше после закрытия // текущего экрана-уведомления (приветствие дня, выброс нейротоксина, // стук в дверь): если есть ещё не показанное событие — на него (по // порядку выброс, затем стук), иначе — в обычный хаб. func (m AppModel) advanceStoryEventQueue() appState { if m.story.PendingNeurotoxinAlert { return stateStoryNeurotoxinAlert } if m.story.PendingDoorKnockAlert { return stateStoryDoorKnockAlert } return stateStoryHub } func (m AppModel) updateStoryMorning(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 } m.state = m.advanceStoryEventQueue() return m, nil } func (m AppModel) viewStoryMorning() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.morning.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(T(m.storyGreeting.textKey()), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) return b.String() } func (m AppModel) updateStoryNeurotoxinAlert(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 } m.story.PendingNeurotoxinAlert = false _ = SaveStoryModeState(m.story) m.state = m.advanceStoryEventQueue() return m, nil } func (m AppModel) viewStoryNeurotoxinAlert() string { var b strings.Builder fmt.Fprintln(&b, errorStyle.Render(T("story.event.neurotoxin.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(Tf("story.event.neurotoxin.text", m.story.ToxinLevel), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) return b.String() } func (m AppModel) updateStoryDoorKnockAlert(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 } m.story.PendingDoorKnockAlert = false _ = SaveStoryModeState(m.story) m.state = m.advanceStoryEventQueue() return m, nil } func (m AppModel) viewStoryDoorKnockAlert() string { var b strings.Builder fmt.Fprintln(&b, errorStyle.Render(T("story.event.doorknock.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(T("story.event.doorknock.text"), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.morning.hint"))) return b.String() } // storyWelcomeTickMsg сигнализирует, что пора показать ещё одно // слово текста приветствия (эффект постепенного появления). type storyWelcomeTickMsg struct{} // storyWelcomeWordInterval — 4 слова в секунду. const storyWelcomeWordInterval = 250 * time.Millisecond func storyWelcomeTick() tea.Cmd { return tea.Tick(storyWelcomeWordInterval, func(time.Time) tea.Msg { return storyWelcomeTickMsg{} }) } // storyTotalWords считает слова во всём тексте, не различая абзацы // (разделитель абзацев "\n\n" сам по себе словом не считается). func storyTotalWords(text string) int { return len(strings.Fields(text)) } // storyRevealPartialText показывает первые n слов text, сохраняя // структуру абзацев (текст может состоять из нескольких абзацев, // разделённых "\n\n") — используется для эффекта постепенного // появления текста. Абзацы показываются по порядку целиком, кроме, // возможно, самого последнего показанного — он может быть показан // частично. func storyRevealPartialText(text string, n int) string { if n <= 0 { return "" } paragraphs := strings.Split(text, "\n\n") var shown []string remaining := n for _, p := range paragraphs { if remaining <= 0 { break } words := strings.Fields(p) if remaining >= len(words) { shown = append(shown, p) remaining -= len(words) } else { shown = append(shown, strings.Join(words[:remaining], " ")) remaining = 0 } } return strings.Join(shown, "\n\n") } func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) { total := storyTotalWords(T("story.welcome.text")) switch msg := msg.(type) { case storyWelcomeTickMsg: if m.storyWelcomeWordsShown >= total { return m, nil } m.storyWelcomeWordsShown++ if m.storyWelcomeWordsShown < total { return m, storyWelcomeTick() } return m, nil case tea.KeyMsg: if msg.String() == "ctrl+c" { m.quitting = true return m, tea.Quit } if m.storyWelcomeWordsShown < total { // текст ещё печатается — любая клавиша сразу показывает // его целиком, а не продолжает в бункер m.storyWelcomeWordsShown = total return m, nil } return m.enterStoryHub(), nil } return m, nil } func (m AppModel) viewStoryWelcome() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.welcome.title"))) fmt.Fprintln(&b) partial := storyRevealPartialText(T("story.welcome.text"), m.storyWelcomeWordsShown) fmt.Fprintln(&b, wrapText(partial, m.termWidth)) fmt.Fprintln(&b) if m.storyWelcomeWordsShown < storyTotalWords(T("story.welcome.text")) { fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint.typing"))) } else { fmt.Fprintln(&b, menuHintStyle.Render(T("story.welcome.hint"))) } return b.String() } func (m AppModel) updateStoryConditions(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 } m.state = stateStoryHub return m, nil } // storyConditionKeys — ключ перевода условия начисления ресурса, по // каждой игре в том же порядке, что и storyHubOrder. var storyConditionKeys = map[StoryResourceID]string{ StoryResTonk: "story.condition.tonk", StoryResDurak: "story.condition.durak", StoryResCheckers: "story.condition.win", StoryResNardy: "story.condition.win", StoryResBlackjack: "story.condition.blackjack", StoryResChess: "story.condition.win", StoryResMinesweeper: "story.condition.minesweeper", StoryResGo: "story.condition.win", StoryResUr: "story.condition.win", StoryResSenet: "story.condition.win", StoryResOneOhOne: "story.condition.oneohone", StoryResThousand: "story.condition.thousand", StoryResKlondike: "story.condition.klondike", StoryResTicTacToe: "story.condition.tictactoe", StoryResPoker: "story.condition.poker", } func (m AppModel) viewStoryConditions() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.conditions.title"))) fmt.Fprintln(&b) for _, id := range storyHubOrder { resDef := storyResourceDefs[id] fmt.Fprintf(&b, "%s\n %s\n", T(resDef.NameKey), dimStyle.Render(T(storyConditionKeys[id]))) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.conditions.hint"))) return b.String() } // storyUseItemDef — одно расходуемое действие на экране // "использовать": какой ресурс тратится, что при этом происходит. type storyUseItemDef struct { ResourceID StoryResourceID EffectKey string Spend func(s *StoryModeState) bool } var storyUseItemDefs = []storyUseItemDef{ {ResourceID: StoryResKlondike, EffectKey: "story.useitem.antidepressant", Spend: func(s *StoryModeState) bool { return s.SpendAntidepressant(time.Now()) }}, {ResourceID: StoryResMinesweeper, EffectKey: "story.useitem.antidote", Spend: func(s *StoryModeState) bool { return s.SpendAntidote() }}, {ResourceID: StoryResGo, EffectKey: "story.useitem.airfilter", Spend: func(s *StoryModeState) bool { return s.SpendAirFilter() }}, {ResourceID: StoryResOneOhOne, EffectKey: "story.useitem.battery", Spend: func(s *StoryModeState) bool { return s.SpendBattery(time.Now()) }}, } func (m AppModel) updateStoryUseItem(msg tea.Msg) (tea.Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if !ok { return m, nil } switch keyMsg.String() { case "ctrl+c": m.quitting = true return m, tea.Quit case "esc", "q": m.state = stateStoryHub return m, nil case "1", "2", "3", "4": idx := int(keyMsg.String()[0] - '1') if idx < 0 || idx >= len(storyUseItemDefs) { return m, nil } if storyUseItemDefs[idx].Spend(m.story) { _ = SaveStoryModeState(m.story) } } return m, nil } func (m AppModel) viewStoryUseItem() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.useitem.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, Tf("story.hub.vitals", m.story.Stress, m.story.Poisoning, m.story.ToxinLevel)) fmt.Fprintln(&b) for i, def := range storyUseItemDefs { resDef := storyResourceDefs[def.ResourceID] count := m.story.Resources[def.ResourceID] fmt.Fprintf(&b, "[%d] %s: %d — %s\n", i+1, T(resDef.NameKey), count, T(def.EffectKey)) } fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.useitem.hint"))) 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 { return m, nil } switch keyMsg.String() { case "ctrl+c": m.quitting = true return m, tea.Quit case "1": // таблетка успокоительного — стресс сразу и без остатка // падает до нуля (в отличие от антидепрессанта, здесь нет // последующего часового восстановления к полу) m.story.Stress = 0 m.story.StressRecoveryActive = false _ = SaveStoryModeState(m.story) m.state = stateStoryHub case "2": m.state = stateStoryEscapeEnding case "esc", "q": m.state = stateStoryHub } return m, nil } func (m AppModel) viewStoryEscapeAttempt() string { var b strings.Builder fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escape.title"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(T("story.escape.text"), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, T("story.escape.stay")) fmt.Fprintln(&b, errorStyle.Render(T("story.escape.leave"))) return b.String() } func (m AppModel) updateStoryEscapeEnding(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) 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"))) fmt.Fprintln(&b) fmt.Fprintln(&b, wrapText(T("story.escapeending.text"), m.termWidth)) fmt.Fprintln(&b) fmt.Fprintln(&b, menuHintStyle.Render(T("story.escapeending.hint"))) return b.String() } func storyDifficultyLabelKey(d StoryDifficulty) string { switch d { case StoryDifficultyEasy: return "story.difficulty.easy" case StoryDifficultyMedium: return "story.difficulty.medium" default: return "story.difficulty.hard" } }