package main import "time" // ПРИМЕЧАНИЕ: инкрементальная (idle/clicker) игра в духе Candy Box 2 // — с постепенным открытием механик по мере накопления ресурса. // Антураж — пародийная стилизация под сеттинг мрачного далёкого // будущего (сервиторы, Муниторум, Скверна, зачистки), без // копирования конкретных защищённых текстов, персонажей или // фракций — только тематическая лексика. // // Прогресс существует только в рамках текущего запуска приложения // (как и во всех остальных играх коллекции) — между перезапусками // ничего не сохраняется. // corpseStarchServitorDef — статическое описание одного тира // генератора пассивного дохода. type corpseStarchServitorDef struct { NameKey string UnlockAt float64 // порог по TotalEarned, при котором тир становится виден BaseCost float64 CostScale float64 YieldPerSec float64 } var corpseStarchServitorDefs = []corpseStarchServitorDef{ {NameKey: "corpsestarch.servitor.skull", UnlockAt: 0, BaseCost: 10, CostScale: 1.15, YieldPerSec: 0.5}, {NameKey: "corpsestarch.servitor.menial", UnlockAt: 200, BaseCost: 100, CostScale: 1.15, YieldPerSec: 3}, {NameKey: "corpsestarch.servitor.cogitator", UnlockAt: 2000, BaseCost: 1000, CostScale: 1.15, YieldPerSec: 20}, } // corpseStarchMissionDef — статическое описание одной доступной // миссии зачистки. type corpseStarchMissionDef struct { NameKey string UnlockAt float64 Duration time.Duration RewardMin float64 RewardMax float64 } var corpseStarchMissionDefs = []corpseStarchMissionDef{ {NameKey: "corpsestarch.mission.vermin", UnlockAt: 800, Duration: 20 * time.Second, RewardMin: 30, RewardMax: 60}, {NameKey: "corpsestarch.mission.chapel", UnlockAt: 800, Duration: 60 * time.Second, RewardMin: 150, RewardMax: 250}, {NameKey: "corpsestarch.mission.waaagh", UnlockAt: 800, Duration: 150 * time.Second, RewardMin: 500, RewardMax: 800}, } // Пороги открытия основных разделов интерфейса (по TotalEarned). const ( corpseStarchUnlockServitors = 20 corpseStarchUnlockShop = 80 corpseStarchUnlockSump = 300 corpseStarchUnlockMissions = 800 ) const ( corpseStarchClickUpgradeBaseCost = 25 corpseStarchClickUpgradeCostScale = 1.6 corpseStarchClickUpgradeGain = 1 ) // Параметры варп-выброса: при достижении этого порога Скверна // "прорывается" наружу — половина текущего запаса крахмала и // половина каждого тира сервиторов теряется, Скверна сбрасывается в // 0, и начинается вторжение хаоситов на фиксированное реальное // время. Пока оно идёт, сервиторы дают заметно меньше дохода — часть // сил станции отвлечена на оборону — а активная защита (повторные // нажатия) даёт шанс на бонус по окончании вторжения. const ( corpseStarchCorruptionBreachThreshold = 3 corpseStarchIncursionDuration = 1 * time.Hour corpseStarchIncursionYieldMultiplier = 0.5 corpseStarchDefendClicksForBonus = 10 corpseStarchDefendBonusReward = 200.0 ) // corpseStarchMission — активная (запущенная) миссия. type corpseStarchMission struct { DefIdx int StartedAt time.Time } // CorpseStarchGameState — полное состояние партии. type CorpseStarchGameState struct { Starch float64 // текущий (тратимый) запас TotalEarned float64 // накоплено за всю партию — используется для порогов открытия разделов ClickYield float64 ClickUpgrades int ServitorCounts []int // по одному счётчику на каждый элемент corpseStarchServitorDefs Corruption int // ChaosIncursionUntil — если не нулевое время и ещё не наступило, // идёт вторжение хаоситов после варп-выброса (см. triggerWarpBreach). // ChaosDefendClicks считает, сколько раз игрок активно отбивался // за текущее вторжение — от этого зависит бонус по его окончании. ChaosIncursionUntil time.Time ChaosDefendClicks int ActiveMission *corpseStarchMission MissionsDone int // BonusMultiplier / BonusUntil — временный множитель к клику от // удачного окунания цепного меча в отстойник. BonusMultiplier float64 BonusUntil time.Time LastTick time.Time Log []string // последние несколько строк лога событий, новые в конце } const corpseStarchLogLimit = 6 // NewCorpseStarchGame создаёт новую партию. func NewCorpseStarchGame(now time.Time) *CorpseStarchGameState { return &CorpseStarchGameState{ ClickYield: 1, BonusMultiplier: 1, ServitorCounts: make([]int, len(corpseStarchServitorDefs)), LastTick: now, } } func (g *CorpseStarchGameState) addLog(key string) { g.Log = append(g.Log, key) if len(g.Log) > corpseStarchLogLimit { g.Log = g.Log[len(g.Log)-corpseStarchLogLimit:] } } func (g *CorpseStarchGameState) earn(amount float64) { if amount <= 0 { return } g.Starch += amount g.TotalEarned += amount } // currentClickYield учитывает временный бонусный множитель, если он // ещё действует на момент now. func (g *CorpseStarchGameState) currentClickYield(now time.Time) float64 { if now.Before(g.BonusUntil) { return g.ClickYield * g.BonusMultiplier } return g.ClickYield } // Requisition — ручное действие ("нажать кнопку"): начисляет один // клик дохода. func (g *CorpseStarchGameState) Requisition(now time.Time) { g.earn(g.currentClickYield(now)) } // Tick начисляет пассивный доход от сервиторов за время, прошедшее // с последнего тика (now должен монотонно не убывать; для тестов // можно подавать произвольные значения). Также сбрасывает истёкший // временный бонус. func (g *CorpseStarchGameState) Tick(now time.Time) { elapsed := now.Sub(g.LastTick).Seconds() g.LastTick = now if elapsed <= 0 { g.checkIncursionResolution(now) return } var perSec float64 for i, def := range corpseStarchServitorDefs { perSec += float64(g.ServitorCounts[i]) * def.YieldPerSec } if g.InChaosIncursion(now) { perSec *= corpseStarchIncursionYieldMultiplier } g.earn(perSec * elapsed) g.checkIncursionResolution(now) } // checkIncursionResolution проверяет, не истекло ли вторжение // хаоситов, и если да — разрешает его (см. resolveChaosIncursion). // Идемпотентно: повторные вызовы после разрешения ничего не делают, // пока не начнётся новое вторжение. func (g *CorpseStarchGameState) checkIncursionResolution(now time.Time) { if g.ChaosIncursionUntil.IsZero() || now.Before(g.ChaosIncursionUntil) { return } g.resolveChaosIncursion() } // servitorCost возвращает стоимость следующей покупки тира idx. func (g *CorpseStarchGameState) servitorCost(idx int) float64 { def := corpseStarchServitorDefs[idx] cost := def.BaseCost for i := 0; i < g.ServitorCounts[idx]; i++ { cost *= def.CostScale } return cost } // BuyServitor покупает одну единицу тира idx, если хватает // трупного крахмала и тир уже открыт по порогу. func (g *CorpseStarchGameState) BuyServitor(idx int) bool { if idx < 0 || idx >= len(corpseStarchServitorDefs) { return false } if g.TotalEarned < corpseStarchServitorDefs[idx].UnlockAt { return false } cost := g.servitorCost(idx) if g.Starch < cost { return false } g.Starch -= cost g.ServitorCounts[idx]++ return true } func (g *CorpseStarchGameState) clickUpgradeCost() float64 { cost := float64(corpseStarchClickUpgradeBaseCost) for i := 0; i < g.ClickUpgrades; i++ { cost *= corpseStarchClickUpgradeCostScale } return cost } // BuyClickUpgrade повышает базовый доход от Реквизиции ("заточка // цепного меча"). func (g *CorpseStarchGameState) BuyClickUpgrade() bool { cost := g.clickUpgradeCost() if g.Starch < cost { return false } g.Starch -= cost g.ClickUpgrades++ g.ClickYield += corpseStarchClickUpgradeGain return true } // CorpseStarchDipOutcome — исход окунания цепного меча в отстойник. type CorpseStarchDipOutcome int const ( DipBonusStarch CorpseStarchDipOutcome = iota DipMachineSpiritBlessing DipNothing DipCorruption ) // DipChainsword бросает цепной меч в отстойник; roll — случайное // число в [0,1), передаётся вызывающим кодом (реальный TUI передаёт // rand.Float64(), тесты — фиксированные значения для детерминизма). func (g *CorpseStarchGameState) DipChainsword(now time.Time, roll float64) CorpseStarchDipOutcome { switch { case roll < 0.5: bonus := 5 + roll*70 // немного разнообразия внутри диапазона [5,40) g.earn(bonus) g.addLog("corpsestarch.log.dip_bonus") return DipBonusStarch case roll < 0.75: g.BonusMultiplier = 2 g.BonusUntil = now.Add(30 * time.Second) g.addLog("corpsestarch.log.dip_blessing") return DipMachineSpiritBlessing case roll < 0.9: g.addLog("corpsestarch.log.dip_nothing") return DipNothing default: g.Corruption++ penalty := g.Starch * 0.1 g.Starch -= penalty g.addLog("corpsestarch.log.dip_corruption") if g.Corruption >= corpseStarchCorruptionBreachThreshold { g.triggerWarpBreach(now) } return DipCorruption } } // triggerWarpBreach — Скверна прорывается варп-выбросом: половина // текущего запаса крахмала и половина каждого тира сервиторов // теряется, Скверна сбрасывается в 0 (выброс её "выпустил"), и // начинается вторжение хаоситов на фиксированное реальное время. func (g *CorpseStarchGameState) triggerWarpBreach(now time.Time) { g.Starch *= 0.5 for i := range g.ServitorCounts { g.ServitorCounts[i] /= 2 } g.Corruption = 0 g.ChaosIncursionUntil = now.Add(corpseStarchIncursionDuration) g.ChaosDefendClicks = 0 g.addLog("corpsestarch.log.warp_breach") } // InChaosIncursion — идёт ли прямо сейчас вторжение хаоситов после // варп-выброса. func (g *CorpseStarchGameState) InChaosIncursion(now time.Time) bool { return !g.ChaosIncursionUntil.IsZero() && now.Before(g.ChaosIncursionUntil) } // ChaosIncursionRemaining — сколько реального времени осталось до // конца вторжения (0, если вторжения сейчас нет). func (g *CorpseStarchGameState) ChaosIncursionRemaining(now time.Time) time.Duration { if !g.InChaosIncursion(now) { return 0 } return g.ChaosIncursionUntil.Sub(now) } // DefendAgainstChaos — активная защита от хаоситов (повторные // нажатия во время вторжения). Возвращает false, если вторжения // сейчас нет — действие недоступно вне его рамок. func (g *CorpseStarchGameState) DefendAgainstChaos(now time.Time) bool { if !g.InChaosIncursion(now) { return false } g.ChaosDefendClicks++ return true } // resolveChaosIncursion завершает текущее вторжение: если игрок // набрал достаточно активных защит за время вторжения — начисляет // бонус в благодарность за стойкость станции. func (g *CorpseStarchGameState) resolveChaosIncursion() { if g.ChaosDefendClicks >= corpseStarchDefendClicksForBonus { g.earn(corpseStarchDefendBonusReward) g.addLog("corpsestarch.log.incursion_repelled_bonus") } else { g.addLog("corpsestarch.log.incursion_repelled") } g.ChaosIncursionUntil = time.Time{} g.ChaosDefendClicks = 0 } // StartMission запускает миссию defIdx, если она открыта и сейчас // нет другой активной миссии. func (g *CorpseStarchGameState) StartMission(now time.Time, defIdx int) bool { if g.ActiveMission != nil { return false } if defIdx < 0 || defIdx >= len(corpseStarchMissionDefs) { return false } if g.TotalEarned < corpseStarchMissionDefs[defIdx].UnlockAt { return false } g.ActiveMission = &corpseStarchMission{DefIdx: defIdx, StartedAt: now} return true } // MissionRemaining возвращает оставшееся время активной миссии (0, // если миссии нет или она уже завершена). func (g *CorpseStarchGameState) MissionRemaining(now time.Time) time.Duration { if g.ActiveMission == nil { return 0 } def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] remaining := def.Duration - now.Sub(g.ActiveMission.StartedAt) if remaining < 0 { return 0 } return remaining } // CollectMission забирает награду завершённой миссии (если она уже // истекла); roll — случайное число в [0,1) для определения награды // в диапазоне [RewardMin,RewardMax). Возвращает (награда, true) при // успехе. func (g *CorpseStarchGameState) CollectMission(now time.Time, roll float64) (float64, bool) { if g.ActiveMission == nil { return 0, false } if g.MissionRemaining(now) > 0 { return 0, false } def := corpseStarchMissionDefs[g.ActiveMission.DefIdx] reward := def.RewardMin + roll*(def.RewardMax-def.RewardMin) g.earn(reward) g.MissionsDone++ g.ActiveMission = nil g.addLog("corpsestarch.log.mission_done") return reward, true } // ServitorsUnlocked, ShopUnlocked, SumpUnlocked, MissionsUnlocked — // проверки открытия соответствующих разделов интерфейса. func (g *CorpseStarchGameState) ServitorsUnlocked() bool { return g.TotalEarned >= corpseStarchUnlockServitors } func (g *CorpseStarchGameState) ShopUnlocked() bool { return g.TotalEarned >= corpseStarchUnlockShop } func (g *CorpseStarchGameState) SumpUnlocked() bool { return g.TotalEarned >= corpseStarchUnlockSump } func (g *CorpseStarchGameState) MissionsUnlocked() bool { return g.TotalEarned >= corpseStarchUnlockMissions } // ServitorUnlocked проверяет, виден ли конкретный тир idx (может // быть ещё не открыт, даже если раздел "Сервиторы" уже виден). func (g *CorpseStarchGameState) ServitorUnlocked(idx int) bool { return g.TotalEarned >= corpseStarchServitorDefs[idx].UnlockAt } // MissionUnlocked проверяет, видна ли конкретная миссия idx. func (g *CorpseStarchGameState) MissionUnlocked(idx int) bool { return g.TotalEarned >= corpseStarchMissionDefs[idx].UnlockAt }