package main import ( "math/rand" "time" ) // Режим истории: набор ресурсов, по одному на каждую "обычную" // (с чётким исходом партии) игру коллекции. Победа в игре // начисляет +1 её ресурса — но не более StoryDailyGrantsLimit раз в // реальные сутки (скользящие 24 часа, как в Трупных батончиках — // без возни с календарём и часовыми поясами): играть можно сколько // угодно, просто сверх лимита выдача ресурса в этот день уже не // происходит. Как только ресурс достигает своей цели, он // "запирается в сейф" — суточный лимит на него больше не тратится // (см. AwardResource), и игра остаётся доступной, а ресурс // продолжает расти дальше без верхнего предела. // // Трупные батончики и Странник в сторимод не включены вовсе: первая // — единственная игра коллекции без дискретной победы или // поражения за одну партию, второй — открытый мир без единого // чёткого условия "победы" за одну партию. Обе доступны как обычно // в режиме "просто игры", но никак не участвуют в накоплении // ресурсов сторимода. // StoryResourceID — идентификатор одного ресурса сторимода, // привязанного к конкретной игре коллекции. type StoryResourceID int const ( StoryResTonk StoryResourceID = iota StoryResDurak StoryResCheckers StoryResNardy StoryResBlackjack StoryResChess StoryResMinesweeper StoryResGo StoryResUr StoryResSenet StoryResOneOhOne StoryResThousand StoryResKlondike StoryResTicTacToe StoryResPoker storyResourceCount // сколько всего ресурсов — не отдельный ресурс, а счётчик ) // Цели по каждому ресурсу — отдельные именованные константы, а не // одно общее число: у каждой игры со временем может появиться своя // собственная цифра. const ( StoryTargetTonk = 100 StoryTargetDurak = 100 StoryTargetCheckers = 100 StoryTargetNardy = 100 StoryTargetBlackjack = 100 StoryTargetChess = 100 StoryTargetMinesweeper = 100 StoryTargetGo = 100 StoryTargetUr = 100 StoryTargetSenet = 100 StoryTargetOneOhOne = 100 StoryTargetThousand = 100 StoryTargetKlondike = 100 StoryTargetTicTacToe = 10 StoryTargetPoker = 100 // storyStartingAmount — стартовое количество каждого ресурса в // начале сторимода. storyStartingAmount = 3 ) // storyResourceDef — статическое описание одного ресурса: ключ // перевода названия и цель. type storyResourceDef struct { NameKey string Target int } var storyResourceDefs = [storyResourceCount]storyResourceDef{ StoryResTonk: {NameKey: "story.resource.tonk", Target: StoryTargetTonk}, StoryResDurak: {NameKey: "story.resource.durak", Target: StoryTargetDurak}, StoryResCheckers: {NameKey: "story.resource.checkers", Target: StoryTargetCheckers}, StoryResNardy: {NameKey: "story.resource.nardy", Target: StoryTargetNardy}, StoryResBlackjack: {NameKey: "story.resource.blackjack", Target: StoryTargetBlackjack}, StoryResChess: {NameKey: "story.resource.chess", Target: StoryTargetChess}, StoryResMinesweeper: {NameKey: "story.resource.minesweeper", Target: StoryTargetMinesweeper}, StoryResGo: {NameKey: "story.resource.go", Target: StoryTargetGo}, StoryResUr: {NameKey: "story.resource.ur", Target: StoryTargetUr}, StoryResSenet: {NameKey: "story.resource.senet", Target: StoryTargetSenet}, StoryResOneOhOne: {NameKey: "story.resource.oneohone", Target: StoryTargetOneOhOne}, StoryResThousand: {NameKey: "story.resource.thousand", Target: StoryTargetThousand}, StoryResKlondike: {NameKey: "story.resource.klondike", Target: StoryTargetKlondike}, StoryResTicTacToe: {NameKey: "story.resource.tictactoe", Target: StoryTargetTicTacToe}, StoryResPoker: {NameKey: "story.resource.poker", Target: StoryTargetPoker}, } // StoryDifficulty — уровень сложности, выбираемый один раз в начале // сторимода и определяющий, какой уровень бота используется в // каждой из игр, где такой выбор вообще есть. type StoryDifficulty int const ( StoryDifficultyEasy StoryDifficulty = iota StoryDifficultyMedium StoryDifficultyHard ) // StoryOnboardingStage — этап знакомства перед тем, как открывается // весь бункер целиком со всеми 15 играми и видимыми числами // ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется // из их текущих значений), потому что суточный расход мог бы // откатить прогресс знакомства назад — переход всегда только вперёд. type StoryOnboardingStage int const ( // StoryOnboardingTicTacToeOnly — доступны только крестики-нолики, // без чисел ресурсов и прочего антуража обычного бункера. StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota // StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел. StoryOnboardingWithCheckers // StoryOnboardingDone — обычный режим, весь бункер как обычно. StoryOnboardingDone ) // StoryOnboardingTicTacToeWinsNeeded — сколько побед/ничьих в // крестики-нолики нужно для открытия шашек. const StoryOnboardingTicTacToeWinsNeeded = 3 // StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно, // чтобы знакомство завершилось и открылся весь бункер. const StoryOnboardingCheckersWinsNeeded = 1 // StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может // произойти начисление ресурса за победу (независимо от того, // сколько партий реально сыграно) — обходной канал доступа к складу // ограничен, чтобы не сжечь оставшиеся схемы. const StoryDailyGrantsLimit = 3 // StoryStressFloor — стресс по умолчанию не опускается ниже этого // значения. const StoryStressFloor = 20 // StoryModeState — всё состояние партии сторимода. type StoryModeState struct { Difficulty StoryDifficulty // Onboarding — этап знакомства (см. StoryOnboardingStage). Новая // партия всегда начинается с StoryOnboardingTicTacToeOnly. Onboarding StoryOnboardingStage Resources [storyResourceCount]int Locked [storyResourceCount]bool // KlondikeWinsToday — сколько партий Косынки выиграно именно // сегодня (сбрасывается каждые прожитые виртуальные сутки, см. // Tick). У Косынки особое правило — 3 победы за один день, а не // серия побед подряд или общий счётчик раундов. KlondikeWinsToday int // DailyGrantsUsed — сколько раз сегодня уже произошло начисление // КАЖДОГО ресурса за победу, по отдельности (см. // StoryDailyGrantsLimit и AwardResource) — не один общий счётчик // на все 15, а свой лимит на каждый ресурс. DailyGrantsUsed [storyResourceCount]int // Стресс, отравление и содержание токсина в воздухе — проценты // от 0 до 100. Stress int Poisoning int ToxinLevel int // FilterUsedToday — фильтр для воздуха израсходован именно // сегодня (сбрасывается каждые новые сутки, см. Tick). // DaysSinceFilterUsed — сколько суток подряд фильтр НЕ // расходовался; чем больше, тем выше вероятность выброса // нейротоксина за очередные сутки (см. storyNeurotoxinProbability). // Сбрасывается в 0 только при реальном расходе фильтра, не при // самом выбросе — если фильтр так и не поменяли, риск остаётся // на максимуме и дальше. FilterUsedToday bool DaysSinceFilterUsed int // DaysSinceDoorKnock — сколько суток подряд не было стука в // дверь; чем больше, тем выше вероятность за очередные сутки (см. // storyDoorKnockProbability). Сбрасывается в 0 при самом стуке. DaysSinceDoorKnock int // PendingNeurotoxinAlert/PendingDoorKnockAlert — хотя бы одно // такое событие произошло с последнего показа соответствующего // экрана-уведомления (см. stateStoryNeurotoxinAlert/ // stateStoryDoorKnockAlert) — показываются только при входе через // меню режимов, по очереди, после приветствия дня. PendingNeurotoxinAlert bool PendingDoorKnockAlert bool // Очистка воздуха, запущенная батарейкой: содержание токсина // плавно снижается к нулю в течение часа с момента запуска. AirPurificationActive bool AirPurificationStart time.Time AirPurificationFromLevel int // Восстановление стресса после антидепрессанта: антидепрессант // может опустить стресс ниже обычного пола (StoryStressFloor), // вплоть до 0, но затем стресс плавно возвращается к полу в // течение часа с момента приёма. StressRecoveryActive bool StressRecoveryStart time.Time StressRecoveryFromLevel int // LastTick — момент последнего учтённого перехода виртуальных // суток (скользящие 24 часа, не календарные сутки) — используется // для сброса суточных счётчиков (KlondikeWinsToday, // DailyGrantsUsed, FilterUsedToday) и розыгрыша суточных событий // (выброс нейротоксина, стук в дверь). LastTick time.Time } // NewStoryModeState начинает новый сторимод с указанной сложностью: // все ресурсы стартуют с storyStartingAmount, ничего не заперто, // стресс 20%, отравление и токсин — 0%. func NewStoryModeState(difficulty StoryDifficulty, now time.Time) *StoryModeState { s := &StoryModeState{Difficulty: difficulty, LastTick: now, Stress: StoryStressFloor} for i := range s.Resources { s.Resources[i] = storyStartingAmount } return s } // Tick сбрасывает суточные счётчики (KlondikeWinsToday, // DailyGrantsUsed) за каждые полные истёкшие "виртуальные сутки" // (скользящие 24 часа с последнего учтённого тика). Возвращает, // сколько таких суток было учтено (0, если ещё не прошли полные // сутки) — вызывающий код использует это, чтобы понять, нужно ли // показать утренний экран. Ресурсы сами по себе больше не // расходуются со временем. // Tick сбрасывает суточные счётчики (KlondikeWinsToday, // DailyGrantsUsed, FilterUsedToday) и разыгрывает суточные события // (выброс нейротоксина, стук в дверь — см. processDayEvents) за // каждые полные истёкшие "виртуальные сутки" (скользящие 24 часа с // последнего учтённого тика), каждый день по отдельности. Возвращает, // сколько таких суток было учтено (0, если ещё не прошли полные // сутки) — вызывающий код использует это, чтобы понять, нужно ли // показать утренний экран. func (s *StoryModeState) Tick(now time.Time) int { elapsed := now.Sub(s.LastTick) days := int(elapsed / (24 * time.Hour)) if days <= 0 { return 0 } rnd := rand.New(rand.NewSource(time.Now().UnixNano())) for i := 0; i < days; i++ { s.processDayEvents(rnd) } s.KlondikeWinsToday = 0 s.DailyGrantsUsed = [storyResourceCount]int{} s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour) return days } // storyNeurotoxinProbability — вероятность выброса нейротоксина за // сутки в зависимости от того, сколько суток подряд фильтр для // воздуха не менялся. 0 суток (фильтр менялся сегодня) — выброса не // может быть вовсе. func storyNeurotoxinProbability(daysSinceFilter int) float64 { switch { case daysSinceFilter >= 3: return 1.0 case daysSinceFilter == 2: return 0.75 case daysSinceFilter >= 1: return 0.5 default: return 0 } } // storyDoorKnockProbability — вероятность стука в дверь за сутки в // зависимости от того, сколько суток подряд его не было. Ровно на // третьи сутки (и далее) — гарантированно. func storyDoorKnockProbability(daysSinceKnock int) float64 { switch { case daysSinceKnock >= 3: return 1.0 case daysSinceKnock == 2: return 0.5 case daysSinceKnock >= 1: return 0.1 default: return 0 } } // processDayEvents разыгрывает события ровно одних суток: выброс // нейротоксина (зависит от того, менялся ли фильтр в эти сутки) и // стук в дверь (не зависит ни от чего, кроме того, сколько суток его // не было). Может произойти и то, и другое в один и тот же день. func (s *StoryModeState) processDayEvents(rnd *rand.Rand) { if s.FilterUsedToday { s.DaysSinceFilterUsed = 0 } else { s.DaysSinceFilterUsed++ } if rnd.Float64() < storyNeurotoxinProbability(s.DaysSinceFilterUsed) { power := 10 + rnd.Intn(61) // 10..70 включительно s.ToxinLevel += power if s.ToxinLevel > 100 { s.ToxinLevel = 100 } s.PendingNeurotoxinAlert = true } s.FilterUsedToday = false s.DaysSinceDoorKnock++ if rnd.Float64() < storyDoorKnockProbability(s.DaysSinceDoorKnock) { s.Stress += 20 if s.Stress > 100 { s.Stress = 100 } s.PendingDoorKnockAlert = true s.DaysSinceDoorKnock = 0 } } // RecordWin начисляет +1 ресурса id безусловно, без учёта суточного // лимита — используется только изнутри AwardResource. Если это // начисление как раз доводит ресурс до цели, он тут же запирается в // сейф, что не блокирует ни саму игру, ни дальнейшее накопление: // после фиксации игра остаётся доступной, и ресурс просто продолжает // расти дальше без верхнего предела. func (s *StoryModeState) RecordWin(id StoryResourceID) { s.Resources[id]++ if !s.Locked[id] && s.Resources[id] >= storyResourceDefs[id].Target { s.Locked[id] = true } } // AwardResource пытается начислить +1 ресурса id за победу, но // только если дневной лимит начислений ИМЕННО ЭТОГО ресурса // (StoryDailyGrantsLimit — свой счётчик на каждый из 15) ещё не // исчерпан за сегодня. Возвращает true, если начисление // действительно произошло. У Косынки помимо этого лимита есть ещё // собственное правило "3 победы за один день" — вызывающий код // (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде // чем вызвать AwardResource. func (s *StoryModeState) AwardResource(id StoryResourceID) bool { if s.DailyGrantsUsed[id] >= StoryDailyGrantsLimit { return false } s.RecordWin(id) s.DailyGrantsUsed[id]++ return true } // spendResource списывает amount единиц ресурса id, если их // достаточно. Если после списания количество опускается ниже цели, // ресурс перестаёт считаться запертым в сейфе — трата действительно // расходует накопленное, а не просто "тратит излишек". func (s *StoryModeState) spendResource(id StoryResourceID, amount int) bool { if s.Resources[id] < amount { return false } s.Resources[id] -= amount if s.Resources[id] < storyResourceDefs[id].Target { s.Locked[id] = false } return true } // SpendAntidepressant тратит 1 антидепрессант (ресурс Косынки) и // снижает стресс на 20%, не опускаясь ниже StoryStressFloor. // SpendAntidepressant тратит 1 антидепрессант (ресурс Косынки) и // снижает стресс на 20%, на этот раз не ограничиваясь обычным полом // StoryStressFloor — стресс может опуститься вплоть до 0%. Если // после этого стресс оказался ниже обычного пола, запускается // часовое восстановление обратно к полу (см. ApplyStressRecovery). func (s *StoryModeState) SpendAntidepressant(now time.Time) bool { if !s.spendResource(StoryResKlondike, 1) { return false } // сначала актуализируем уже идущее восстановление (если оно // есть), чтобы вычитать 20 из настоящего текущего значения, а не // из устаревшего s.ApplyStressRecovery(now) s.Stress -= 20 if s.Stress < 0 { s.Stress = 0 } if s.Stress < StoryStressFloor { s.StressRecoveryActive = true s.StressRecoveryStart = now s.StressRecoveryFromLevel = s.Stress } else { s.StressRecoveryActive = false } return true } // ApplyStressRecovery проверяет, идёт ли сейчас восстановление // стресса после антидепрессанта, и если да — плавно поднимает // стресс от StressRecoveryFromLevel к StoryStressFloor, пропорционально // прошедшему времени, вплоть до полного завершения через час. func (s *StoryModeState) ApplyStressRecovery(now time.Time) { if !s.StressRecoveryActive { return } elapsed := now.Sub(s.StressRecoveryStart) if elapsed >= time.Hour { s.Stress = StoryStressFloor s.StressRecoveryActive = false return } frac := float64(elapsed) / float64(time.Hour) s.Stress = s.StressRecoveryFromLevel + int(float64(StoryStressFloor-s.StressRecoveryFromLevel)*frac) if s.Stress > StoryStressFloor { s.Stress = StoryStressFloor } } // SpendAntidote тратит 1 антидот (ресурс Сапёра) и снижает // отравление на 20%, не опускаясь ниже нуля. func (s *StoryModeState) SpendAntidote() bool { if !s.spendResource(StoryResMinesweeper, 1) { return false } s.Poisoning -= 20 if s.Poisoning < 0 { s.Poisoning = 0 } return true } // SpendAirFilter тратит 1 фильтр для воздуха (ресурс Го) — отмечает // фильтр использованным сегодня. При обработке перехода суток (см. // Tick) это защитит от выброса нейротоксина за сегодняшний день и // сбросит счётчик дней без замены (DaysSinceFilterUsed) в 0. func (s *StoryModeState) SpendAirFilter() bool { if !s.spendResource(StoryResGo, 1) { return false } s.FilterUsedToday = true return true } // SpendBattery тратит 1 батарейку (ресурс 101) и запускает очистку // воздуха: содержание токсина плавно снижается к нулю в течение часа // с этого момента (см. ApplyAirPurification). func (s *StoryModeState) SpendBattery(now time.Time) bool { if !s.spendResource(StoryResOneOhOne, 1) { return false } s.AirPurificationActive = true s.AirPurificationStart = now s.AirPurificationFromLevel = s.ToxinLevel return true } // ApplyAirPurification проверяет, идёт ли сейчас запущенная батарейкой // очистка воздуха, и если да — снижает содержание токсина // пропорционально прошедшему времени, вплоть до полного завершения // (токсин = 0) через час с момента запуска. func (s *StoryModeState) ApplyAirPurification(now time.Time) { if !s.AirPurificationActive { return } elapsed := now.Sub(s.AirPurificationStart) if elapsed >= time.Hour { s.ToxinLevel = 0 s.AirPurificationActive = false return } frac := float64(elapsed) / float64(time.Hour) s.ToxinLevel = s.AirPurificationFromLevel - int(float64(s.AirPurificationFromLevel)*frac) if s.ToxinLevel < 0 { s.ToxinLevel = 0 } } // AdvanceOnboarding проверяет, не пора ли перейти на следующий этап // знакомства, исходя из текущих значений ресурсов крестиков-ноликов // и шашек. Переход всегда только вперёд (см. StoryOnboardingStage) — // вызывать безопасно после любой партии, не только тех двух игр. // Возвращает true, если именно этим вызовом знакомство полностью // завершилось (переход в StoryOnboardingDone) — по этому сигналу // вызывающий код показывает приветствие в первый и единственный раз. func (s *StoryModeState) AdvanceOnboarding() (justCompleted bool) { switch s.Onboarding { case StoryOnboardingTicTacToeOnly: // ресурсы стартуют не с нуля, а с storyStartingAmount — порог // считаем от стартового значения, иначе условие "3 победы" // выполнилось бы сразу, без единой реально сыгранной партии if s.Resources[StoryResTicTacToe] >= storyStartingAmount+StoryOnboardingTicTacToeWinsNeeded { s.Onboarding = StoryOnboardingWithCheckers } case StoryOnboardingWithCheckers: if s.Resources[StoryResCheckers] >= storyStartingAmount+StoryOnboardingCheckersWinsNeeded { s.Onboarding = StoryOnboardingDone return true } } return false } // AllResourcesLocked проверяет, заперты ли уже все ресурсы сторимода // (все игры, кроме Странника и Трупных батончиков, которые в // сторимоде не участвуют вовсе). func (s *StoryModeState) AllResourcesLocked() bool { for _, locked := range s.Locked { if !locked { return false } } return true } // Complete проверяет полное завершение сторимода: заперты все // ресурсы. func (s *StoryModeState) Complete() bool { return s.AllResourcesLocked() }