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

522 lines
25 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 (
"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()
}