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 ) // 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 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 { return } var perSec float64 for i, def := range corpseStarchServitorDefs { perSec += float64(g.ServitorCounts[i]) * def.YieldPerSec } g.earn(perSec * elapsed) } // 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") return DipCorruption } } // 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 }