go_games_collection/corpsestarch_game.go
2026-07-15 18:02:34 +03:00

547 lines
24 KiB
Go
Raw 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 "time"
// ПРИМЕЧАНИЕ: инкрементальная (idle/clicker) игра в духе Candy Box 2
// — с постепенным открытием механик по мере накопления ресурса.
// Антураж — пародийная стилизация под сеттинг мрачного далёкого
// будущего (сервиторы, Муниторум, Скверна, зачистки), без
// копирования конкретных защищённых текстов, персонажей или
// фракций — только тематическая лексика.
//
// Прогресс существует только в рамках текущего запуска приложения
// (как и во всех остальных играх коллекции) — между перезапусками
// ничего не сохраняется.
// corpseStarchServitorDef — статическое описание одного тира
// генератора пассивного дохода. DemonicNameKey — альтернативное имя
// после перерождения в демоническую сущность (см. AcceptChaosOffer);
// сам механизм (стоимость, доход) не меняется — меняется только
// то, как это называется.
type corpseStarchServitorDef struct {
NameKey string
DemonicNameKey string
UnlockAt float64 // порог по TotalEarned, при котором тир становится виден
BaseCost float64
CostScale float64
YieldPerSec float64
}
var corpseStarchServitorDefs = []corpseStarchServitorDef{
{NameKey: "corpsestarch.servitor.skull", DemonicNameKey: "corpsestarch.servitor.pinkhorror", UnlockAt: 0, BaseCost: 10, CostScale: 1.15, YieldPerSec: 0.5},
{NameKey: "corpsestarch.servitor.menial", DemonicNameKey: "corpsestarch.servitor.screamer", UnlockAt: 200, BaseCost: 100, CostScale: 1.15, YieldPerSec: 3},
{NameKey: "corpsestarch.servitor.cogitator", DemonicNameKey: "corpsestarch.servitor.lordofchange", UnlockAt: 2000, BaseCost: 1000, CostScale: 1.15, YieldPerSec: 20},
}
// effectiveNameKey возвращает ключ имени тира с учётом текущего
// режима партии (обычный/демонический после перерождения).
func (def corpseStarchServitorDef) effectiveNameKey(mode CorpseStarchMode) string {
if mode == CorpseStarchModeDemonic {
return def.DemonicNameKey
}
return def.NameKey
}
// modeKey выбирает один из двух ключей перевода в зависимости от
// текущего режима партии — для сообщений и надписей, у которых (в
// отличие от сервиторов/миссий) нет отдельного def-объекта с парой
// имперского/демонического ключа.
func (g *CorpseStarchGameState) modeKey(imperialKey, demonicKey string) string {
if g.Mode == CorpseStarchModeDemonic {
return demonicKey
}
return imperialKey
}
// corpseStarchMissionDef — статическое описание одной доступной
// миссии зачистки (или, после перерождения, вторжения на планеты
// Империума — тот же механизм, другое название).
type corpseStarchMissionDef struct {
NameKey string
DemonicNameKey string
UnlockAt float64
Duration time.Duration
RewardMin float64
RewardMax float64
}
var corpseStarchMissionDefs = []corpseStarchMissionDef{
{NameKey: "corpsestarch.mission.vermin", DemonicNameKey: "corpsestarch.mission.borderworld", UnlockAt: 800, Duration: 20 * time.Second, RewardMin: 30, RewardMax: 60},
{NameKey: "corpsestarch.mission.chapel", DemonicNameKey: "corpsestarch.mission.shrineworld", UnlockAt: 800, Duration: 60 * time.Second, RewardMin: 150, RewardMax: 250},
{NameKey: "corpsestarch.mission.waaagh", DemonicNameKey: "corpsestarch.mission.sector", UnlockAt: 800, Duration: 150 * time.Second, RewardMin: 500, RewardMax: 800},
}
// effectiveNameKey возвращает ключ названия миссии с учётом текущего
// режима партии.
func (def corpseStarchMissionDef) effectiveNameKey(mode CorpseStarchMode) string {
if mode == CorpseStarchModeDemonic {
return def.DemonicNameKey
}
return def.NameKey
}
// Пороги открытия основных разделов интерфейса (по 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
)
// Параметры предложения перейти на сторону Хаоса и самого
// перерождения: после corpseStarchChaosMarksForOffer варп-выбросов
// подряд (без отказа, который сбрасывает счётчик) появляется выбор
// — отказаться (награда и сброс счётчика) или принять (переход в
// демонический режим после суток полной недееспособности).
const (
corpseStarchChaosMarksForOffer = 5
corpseStarchRebirthDuration = 24 * time.Hour
corpseStarchChaosDeclineReward = 500.0
)
// corpseStarchMission — активная (запущенная) миссия.
type corpseStarchMission struct {
DefIdx int
StartedAt time.Time
}
// CorpseStarchGameState — полное состояние партии.
// CorpseStarchMode — обычный режим или демоническая сущность после
// перерождения (см. AcceptChaosOffer). Меняет только НАЗВАНИЯ вещей
// (ресурс, тиры сервиторов, миссии, счётчик Скверны/отблеска) — сам
// игровой механизм остаётся тем же самым.
type CorpseStarchMode int
const (
CorpseStarchModeImperial CorpseStarchMode = iota
CorpseStarchModeDemonic
)
// CorpseStarchGameState — полное состояние партии.
type CorpseStarchGameState struct {
Starch float64 // текущий (тратимый) запас
TotalEarned float64 // накоплено за всю партию — используется для порогов открытия разделов
ClickYield float64
ClickUpgrades int
ServitorCounts []int // по одному счётчику на каждый элемент corpseStarchServitorDefs — уменьшается варп-выбросом
// ServitorPurchases — сколько раз куплен каждый тир за всю
// партию. В отличие от ServitorCounts, варп-выброс её НЕ трогает:
// цена следующей покупки должна оставаться такой же, какой была
// до потери инфраструктуры, а не дешеветь только потому, что
// действующих юнитов вдруг стало меньше.
ServitorPurchases []int
Corruption int
// ChaosIncursionUntil — если не нулевое время и ещё не наступило,
// идёт вторжение хаоситов после варп-выброса (см. triggerWarpBreach).
// ChaosDefendClicks считает, сколько раз игрок активно отбивался
// за текущее вторжение — от этого зависит бонус по его окончании.
ChaosIncursionUntil time.Time
ChaosDefendClicks int
// ChaosMarks растёт на 1 после каждого варп-выброса. При
// достижении corpseStarchChaosMarksForOffer появляется
// предложение перейти на сторону Хаоса (см. ChaosOfferPending).
ChaosMarks int
// Mode — обычный режим или уже переродившаяся демоническая
// сущность. RebirthUntil — если не нулевое и ещё не наступило,
// идёт само перерождение: все обычные действия недоступны, пока
// оно не завершится (см. InRebirth).
Mode CorpseStarchMode
RebirthUntil time.Time
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)),
ServitorPurchases: 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
g.checkRebirthCompletion(now)
if elapsed <= 0 || g.InRebirth(now) {
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)
}
// checkRebirthCompletion переключает партию в демонический режим,
// как только суточное перерождение подходит к концу. Идемпотентно.
func (g *CorpseStarchGameState) checkRebirthCompletion(now time.Time) {
if g.RebirthUntil.IsZero() || now.Before(g.RebirthUntil) {
return
}
g.RebirthUntil = time.Time{}
g.Mode = CorpseStarchModeDemonic
g.addLog("corpsestarch.log.rebirth_complete")
}
// 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.ServitorPurchases[idx]; i++ {
cost *= def.CostScale
}
return cost
}
// BuyServitor покупает одну единицу тира idx, если хватает
// трупного крахмала, тир уже открыт по порогу, и сейчас не идёт
// вторжение хаоситов (пока оно не закончится, новую инфраструктуру
// строить нельзя — часть станции физически недоступна под атакой).
func (g *CorpseStarchGameState) BuyServitor(idx int, now time.Time) bool {
if idx < 0 || idx >= len(corpseStarchServitorDefs) {
return false
}
if g.InChaosIncursion(now) {
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]++
g.ServitorPurchases[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.ChaosMarks++
g.addLog(g.modeKey("corpsestarch.log.warp_breach", "corpsestarch.log.warp_breach.demonic"))
}
// ChaosOfferPending — пора ли показать предложение перейти на
// сторону Хаоса (набралось достаточно меток, партия ещё в обычном
// режиме и перерождение ещё не идёт).
func (g *CorpseStarchGameState) ChaosOfferPending() bool {
return g.Mode == CorpseStarchModeImperial &&
g.ChaosMarks >= corpseStarchChaosMarksForOffer &&
g.RebirthUntil.IsZero()
}
// DeclineChaosOffer отклоняет предложение: счётчик меток сбрасывается
// в 0, взамен начисляется разовая награда.
func (g *CorpseStarchGameState) DeclineChaosOffer() {
g.earn(corpseStarchChaosDeclineReward)
g.ChaosMarks = 0
g.addLog("corpsestarch.log.chaos_declined")
}
// AcceptChaosOffer принимает предложение: начинается перерождение —
// на corpseStarchRebirthDuration партия становится полностью
// недееспособной (см. InRebirth), а по его завершении Mode
// переключается на демонический (см. Tick).
func (g *CorpseStarchGameState) AcceptChaosOffer(now time.Time) {
g.RebirthUntil = now.Add(corpseStarchRebirthDuration)
g.ChaosMarks = 0
g.addLog("corpsestarch.log.rebirth_begins")
}
// InRebirth — идёт ли прямо сейчас перерождение (после принятия
// предложения Хаоса, до его завершения).
func (g *CorpseStarchGameState) InRebirth(now time.Time) bool {
return !g.RebirthUntil.IsZero() && now.Before(g.RebirthUntil)
}
// RebirthRemaining — сколько реального времени осталось до конца
// перерождения (0, если оно сейчас не идёт).
func (g *CorpseStarchGameState) RebirthRemaining(now time.Time) time.Duration {
if !g.InRebirth(now) {
return 0
}
return g.RebirthUntil.Sub(now)
}
// 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(g.modeKey("corpsestarch.log.incursion_repelled_bonus", "corpsestarch.log.incursion_repelled_bonus.demonic"))
} else {
g.addLog(g.modeKey("corpsestarch.log.incursion_repelled", "corpsestarch.log.incursion_repelled.demonic"))
}
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
}