Added new features

This commit is contained in:
Magnus Root 2026-07-07 09:58:26 +03:00
parent f2a21d8421
commit 4379e1a092
8 changed files with 358 additions and 20 deletions

View file

@ -59,6 +59,21 @@ const (
corpseStarchClickUpgradeGain = 1
)
// Параметры варп-выброса: при достижении этого порога Скверна
// "прорывается" наружу — половина текущего запаса крахмала и
// половина каждого тира сервиторов теряется, Скверна сбрасывается в
// 0, и начинается вторжение хаоситов на фиксированное реальное
// время. Пока оно идёт, сервиторы дают заметно меньше дохода — часть
// сил станции отвлечена на оборону — а активная защита (повторные
// нажатия) даёт шанс на бонус по окончании вторжения.
const (
corpseStarchCorruptionBreachThreshold = 3
corpseStarchIncursionDuration = 1 * time.Hour
corpseStarchIncursionYieldMultiplier = 0.5
corpseStarchDefendClicksForBonus = 10
corpseStarchDefendBonusReward = 200.0
)
// corpseStarchMission — активная (запущенная) миссия.
type corpseStarchMission struct {
DefIdx int
@ -77,6 +92,13 @@ type CorpseStarchGameState struct {
Corruption int
// ChaosIncursionUntil — если не нулевое время и ещё не наступило,
// идёт вторжение хаоситов после варп-выброса (см. triggerWarpBreach).
// ChaosDefendClicks считает, сколько раз игрок активно отбивался
// за текущее вторжение — от этого зависит бонус по его окончании.
ChaosIncursionUntil time.Time
ChaosDefendClicks int
ActiveMission *corpseStarchMission
MissionsDone int
@ -140,13 +162,29 @@ 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.
@ -231,10 +269,68 @@ func (g *CorpseStarchGameState) DipChainsword(now time.Time, roll float64) Corps
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 {

View file

@ -178,6 +178,143 @@ func TestDipChainswordCorruptionOutcome(t *testing.T) {
}
}
func TestWarpBreachTriggersAtCorruptionThreshold(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.Starch = 1000
g.TotalEarned = 1000
g.ServitorCounts[0] = 10
g.ServitorCounts[1] = 4
// два неудачных окунания — Скверна 1, потом 2, выброс ещё не должен случиться
g.DipChainsword(csT(0), 0.95)
g.DipChainsword(csT(1), 0.95)
if g.Corruption != 2 {
t.Fatalf("ожидалась Скверна 2 после двух неудач, получено %d", g.Corruption)
}
if g.InChaosIncursion(csT(1)) {
t.Fatalf("вторжение не должно было начаться раньше порога")
}
// третье неудачное окунание достигает порога — должен случиться выброс
starchBefore := g.Starch
g.DipChainsword(csT(2), 0.95)
if g.Corruption != 0 {
t.Errorf("Скверна должна была сброситься в 0 после выброса, получено %d", g.Corruption)
}
if !g.InChaosIncursion(csT(2)) {
t.Fatalf("вторжение хаоситов должно было начаться сразу после выброса")
}
// после выброса запас должен был уменьшиться примерно вдвое от
// того, что было ДО штрафа за само неудачное окунание (10%), —
// проверяем лишь то, что итог заметно меньше половины исходного
// запаса до всей цепочки событий, без привязки к точной формуле
if g.Starch >= starchBefore {
t.Errorf("запас должен был заметно уменьшиться при выбросе, было %v, стало %v", starchBefore, g.Starch)
}
if g.ServitorCounts[0] != 5 || g.ServitorCounts[1] != 2 {
t.Errorf("количество сервиторов должно было уполовиниться (10->5, 4->2), получено %v", g.ServitorCounts)
}
}
func TestWarpBreachHalvesServitorsWithFloorDivision(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.ServitorCounts[0] = 7 // нечётное число — проверяем округление вниз
g.Corruption = corpseStarchCorruptionBreachThreshold - 1
g.DipChainsword(csT(0), 0.95) // добивает Скверну до порога
if g.ServitorCounts[0] != 3 {
t.Errorf("7 сервиторов должны были уполовиниться с округлением вниз до 3, получено %d", g.ServitorCounts[0])
}
}
func TestChaosIncursionReducesServitorYield(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.ServitorCounts[0] = 10 // 10 * 0.5/сек = 5/сек в обычном режиме
// ChaosIncursionUntil остаётся нулевым — вторжения нет вообще
g.Tick(csT(10))
normalEarn := g.Starch
g2 := NewCorpseStarchGame(csT(0))
g2.ServitorCounts[0] = 10
g2.ChaosIncursionUntil = csT(100) // вторжение идёт весь этот тик (10 < 100)
g2.Tick(csT(10))
if g2.Starch >= normalEarn {
t.Errorf("доход во время вторжения должен быть меньше обычного: обычный %v, во время вторжения %v", normalEarn, g2.Starch)
}
if g2.Starch != normalEarn*corpseStarchIncursionYieldMultiplier {
t.Errorf("ожидался ровно множитель %v от обычного дохода, получено %v (обычный: %v)",
corpseStarchIncursionYieldMultiplier, g2.Starch, normalEarn)
}
}
func TestDefendAgainstChaosOnlyWorksDuringIncursion(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
if g.DefendAgainstChaos(csT(0)) {
t.Errorf("защита не должна была сработать без активного вторжения")
}
if g.ChaosDefendClicks != 0 {
t.Errorf("счётчик защиты не должен был увеличиться вне вторжения")
}
g.ChaosIncursionUntil = csT(100)
if !g.DefendAgainstChaos(csT(0)) {
t.Errorf("защита должна была сработать во время вторжения")
}
if g.ChaosDefendClicks != 1 {
t.Errorf("ожидался счётчик защиты 1, получено %d", g.ChaosDefendClicks)
}
}
func TestIncursionResolvesAfterDurationWithoutBonus(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.ChaosIncursionUntil = csT(10)
g.ChaosDefendClicks = corpseStarchDefendClicksForBonus - 1 // недостаточно для бонуса
starchBefore := g.Starch
g.Tick(csT(11)) // время вышло за отметку окончания вторжения
if g.InChaosIncursion(csT(11)) {
t.Errorf("вторжение должно было завершиться")
}
if !g.ChaosIncursionUntil.IsZero() {
t.Errorf("ChaosIncursionUntil должен был сброситься в нулевое значение")
}
if g.ChaosDefendClicks != 0 {
t.Errorf("счётчик защиты должен был сброситься после разрешения вторжения")
}
if g.Starch != starchBefore {
t.Errorf("без достаточной защиты бонус не должен был начислиться, было %v, стало %v", starchBefore, g.Starch)
}
}
func TestIncursionResolvesWithBonusWhenDefendedEnough(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.ChaosIncursionUntil = csT(10)
g.ChaosDefendClicks = corpseStarchDefendClicksForBonus
starchBefore := g.Starch
g.Tick(csT(11))
if g.Starch != starchBefore+corpseStarchDefendBonusReward {
t.Errorf("ожидался бонус %v за успешную защиту, было %v, стало %v",
corpseStarchDefendBonusReward, starchBefore, g.Starch)
}
}
func TestChaosIncursionRemainingCountdown(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
g.ChaosIncursionUntil = csT(100)
remaining := g.ChaosIncursionRemaining(csT(40))
if remaining != 60*time.Second {
t.Errorf("ожидалось 60 секунд до конца вторжения, получено %v", remaining)
}
if g.ChaosIncursionRemaining(csT(200)) != 0 {
t.Errorf("после окончания вторжения обратный отсчёт должен быть 0")
}
}
func TestStartMissionRequiresUnlockAndNoActiveMission(t *testing.T) {
g := NewCorpseStarchGame(csT(0))
if g.StartMission(csT(0), 0) {

View file

@ -136,8 +136,22 @@ func (m CorpseStarchModel) buildActions(now time.Time) []corpseStarchAction {
case DipNothing:
m.setInfo("corpsestarch.log.dip_nothing")
case DipCorruption:
if m.game.InChaosIncursion(now) {
m.setInfo("corpsestarch.log.warp_breach")
} else {
m.setInfo("corpsestarch.log.dip_corruption")
}
}
},
})
}
if g.InChaosIncursion(now) {
actions = append(actions, corpseStarchAction{
Label: Tf("corpsestarch.action.defend", g.ChaosDefendClicks),
Handle: func(m *CorpseStarchModel, now time.Time) {
m.game.DefendAgainstChaos(now)
m.setInfo("corpsestarch.msg.defended")
},
})
}
@ -243,12 +257,17 @@ func formatStarch(v float64) string {
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
m := int(d.Minutes())
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if m > 0 {
switch {
case h > 0:
return fmt.Sprintf("%dh%02dm%02ds", h, m, s)
case m > 0:
return fmt.Sprintf("%dm%02ds", m, s)
}
default:
return fmt.Sprintf("%ds", s)
}
}
func (m CorpseStarchModel) View() string {
@ -276,6 +295,11 @@ func (m CorpseStarchModel) View() string {
}
fmt.Fprintln(&b)
if g.InChaosIncursion(now) {
fmt.Fprintln(&b, errorStyle.Render(Tf("corpsestarch.incursion.active", formatDuration(g.ChaosIncursionRemaining(now)))))
fmt.Fprintln(&b)
}
if g.MissionsUnlocked() && g.ActiveMission != nil {
def := corpseStarchMissionDefs[g.ActiveMission.DefIdx]
remaining := g.MissionRemaining(now)

View file

@ -1,6 +1,8 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
)
@ -15,6 +17,53 @@ func TestCorpseStarchModelRequisitionViaKey(t *testing.T) {
}
}
func TestCorpseStarchModelDefendActionAppearsDuringIncursion(t *testing.T) {
m := NewCorpseStarchModel()
before := len(m.buildActions(m.game.LastTick))
m.game.ChaosIncursionUntil = m.game.LastTick.Add(1)
after := m.buildActions(m.game.LastTick)
if len(after) != before+1 {
t.Fatalf("ожидалось на одно действие больше во время вторжения, было %d, стало %d", before, len(after))
}
if after[len(after)-1].Label != Tf("corpsestarch.action.defend", 0) {
t.Errorf("последнее действие должно быть 'отбиваться', получено: %q", after[len(after)-1].Label)
}
}
func TestCorpseStarchModelDefendActionIncrementsCounterViaKey(t *testing.T) {
m := NewCorpseStarchModel()
m.game.ChaosIncursionUntil = time.Now().Add(time.Hour)
actions := m.buildActions(time.Now())
defendKey := fmt.Sprintf("%d", len(actions))
next, _ := m.Update(key(defendKey))
m = next.(CorpseStarchModel)
if m.game.ChaosDefendClicks != 1 {
t.Errorf("ожидался счётчик защиты 1 после нажатия, получено %d", m.game.ChaosDefendClicks)
}
}
func TestCorpseStarchModelWarpBreachVisibleInView(t *testing.T) {
m := NewCorpseStarchModel()
m.game.Starch = 1000
m.game.ServitorCounts[0] = 10
m.game.DipChainsword(m.game.LastTick, 0.95)
m.game.DipChainsword(m.game.LastTick, 0.95)
m.game.DipChainsword(m.game.LastTick, 0.95)
view := m.View()
if !strings.Contains(view, T("corpsestarch.log.warp_breach")) {
t.Errorf("экран должен был показать сообщение о варп-выбросе в журнале")
}
if !strings.Contains(view, "ВТОРЖЕНИЕ ХАОСА") {
t.Errorf("экран должен был показать баннер активного вторжения")
}
if m.game.ServitorCounts[0] != 5 {
t.Errorf("сервиторы должны были уполовиниться, получено %d", m.game.ServitorCounts[0])
}
}
func TestCorpseStarchModelActionsExpandAsUnlocked(t *testing.T) {
withTempConfigDir(t)
m := NewCorpseStarchModel()

BIN
ggc (Stored with Git LFS)

Binary file not shown.

BIN
ggc.exe (Stored with Git LFS)

Binary file not shown.

View file

@ -1121,6 +1121,19 @@ const corpseStarchRulesRU = `ТРУПНЫЕ БАТОНЧИКИ (CORPSE-STARCH BO
(от 20 секунд до нескольких минут); по завершении можно забрать
награду.
СКВЕРНА И ВАРП-ВЫБРОС
Каждое неудачное окунание меча в отстойник добавляет единицу
Скверны. При достижении 3 она прорывается варп-выбросом: половина
текущего запаса крахмала и половина каждого тира сервиторов
теряется, Скверна сбрасывается в 0, и начинается вторжение
культистов Хаоса на целый час реального времени. Пока оно идёт,
сервиторы дают вдвое меньше дохода часть сил станции отвлечена на
оборону. Появляется отдельное действие "отбиваться от культистов"
каждое нажатие засчитывается; если за время вторжения накопится
достаточно активных защит, по его окончании начисляется бонус
крахмала в благодарность за стойкость станции. Цикл может
повториться Скверна снова начинает копиться с нуля.
УПРАВЛЕНИЕ
1-9 выбрать пронумерованное действие (список меняется по мере
открытия новых разделов номера могут "переезжать")
@ -1167,6 +1180,19 @@ SECTIONS THAT UNLOCK AS YOU GATHER MORE
time (from 20 seconds to a few minutes); collect the reward once
it's done.
CORRUPTION AND WARP BREACH
Every unlucky chainsword dip adds one point of Corruption. Once it
reaches 3, it erupts in a warp breach: half your current starch
stockpile and half of every servitor tier are lost, Corruption
resets to 0, and a Chaos cultist incursion begins for a full hour
of real time. While it's ongoing, servitors yield only half their
usual income part of the station's strength is diverted to
defense. A separate "fight off the cultists" action appears each
press counts toward your defense; if you rack up enough active
defenses during the incursion, a bonus shipment of starch is
awarded once it ends, as thanks for the station's resolve. The
cycle can repeat Corruption starts building up from zero again.
CONTROLS
1-9 choose a numbered action (the list grows as you unlock more
sections numbers may shift around)

View file

@ -321,12 +321,14 @@ func init() {
"corpsestarch.action.dip": {"Окунуть цепной меч в отстойник", "Dip your chainsword in the sump"},
"corpsestarch.action.start_mission": {"Начать зачистку: %s (%s)", "Start purge: %s (%s)"},
"corpsestarch.action.collect_mission": {"Забрать награду за зачистку", "Collect purge reward"},
"corpsestarch.action.defend": {"Отбиваться от культистов Хаоса (заявлено атак: %d)", "Fight off Chaos cultists (attacks logged: %d)"},
"corpsestarch.msg.requisitioned": {"Реквизиция одобрена Муниторумом.", "Requisition approved by the Munitorum."},
"corpsestarch.msg.servitor_bought": {"Новая единица введена в строй.", "A new unit has been brought online."},
"corpsestarch.msg.upgrade_bought": {"Цепной меч заточен острее.", "The chainsword is sharper now."},
"corpsestarch.msg.cannot_afford": {"Недостаточно трупного крахмала.", "Not enough corpse starch."},
"corpsestarch.msg.mission_started": {"Отряд отправлен на зачистку.", "A squad has been dispatched on the purge."},
"corpsestarch.msg.defended": {"Вы отбили атаку культистов.", "You fought off a cultist attack."},
"corpsestarch.mission.inprogress": {"Зачистка идёт: %s (осталось %s)", "Purge underway: %s (%s remaining)"},
"corpsestarch.mission.ready": {"Зачистка завершена: %s — заберите награду!", "Purge complete: %s — collect your reward!"},
@ -336,6 +338,10 @@ func init() {
"corpsestarch.log.dip_blessing": {"Дух машины благословил ваш клинок — временный прирост дохода.", "The machine spirit blessed your blade — temporary income boost."},
"corpsestarch.log.dip_nothing": {"Отстойник ответил тишиной.", "The sump answers only with silence."},
"corpsestarch.log.dip_corruption": {"Скверна коснулась вашего снаряжения.", "Taint has touched your wargear."},
"corpsestarch.incursion.active": {"ВТОРЖЕНИЕ ХАОСА! Культисты атакуют станцию — осталось: %s", "CHAOS INCURSION! Cultists are attacking the station — time left: %s"},
"corpsestarch.log.warp_breach": {"Скверна прорвалась варп-выбросом! Половина запасов и оборудования потеряна — из пробоя хлынули культисты Хаоса.", "The taint erupts in a warp breach! Half your stockpile and equipment are lost — Chaos cultists pour through the rupture."},
"corpsestarch.log.incursion_repelled": {"Вторжение культистов отражено. Обошлось малой кровью.", "The cultist incursion has been repelled. It could have been worse."},
"corpsestarch.log.incursion_repelled_bonus": {"Вторжение отражено! За стойкость станция получила дополнительный груз трупного крахмала.", "The incursion has been repelled! For your resolve, the station received a bonus shipment of corpse starch."},
"corpsestarch.log.mission_done": {"Отряд вернулся с зачистки с трофеями.", "The squad returned from the purge with spoils."},
"corpsestarch.servitor.skull": {"Сервочерп", "Servo-Skull"},