go_games_collection/story_tui.go
2026-07-16 09:53:25 +03:00

1205 lines
No EOL
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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