package main import ( "fmt" "strings" "testing" "time" ) func TestCorpseStarchModelRequisitionViaKey(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() next, _ := m.Update(key("1")) m = next.(CorpseStarchModel) if m.game.Starch != 1 { t.Fatalf("ожидался запас 1 после нажатия [1], получено %v", m.game.Starch) } } func TestCorpseStarchModelDipActionHiddenDuringIncursion(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.TotalEarned = corpseStarchUnlockSump // открываем отстойник m.game.ChaosIncursionUntil = m.game.LastTick.Add(time.Hour) during := m.buildActions(m.game.LastTick) for _, a := range during { if a.Label == T("corpsestarch.action.dip") { t.Errorf("окунание меча не должно предлагаться во время вторжения — иначе новая Скверна могла бы перезапустить уже идущее вторжение") } } } func TestCorpseStarchModelDipActionReappearsAfterIncursionEnds(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.TotalEarned = corpseStarchUnlockSump m.game.ChaosIncursionUntil = time.Time{} // вторжения нет actions := m.buildActions(m.game.LastTick) found := false for _, a := range actions { if a.Label == T("corpsestarch.action.dip") { found = true } } if !found { t.Errorf("окунание меча должно быть доступно вне вторжения") } } // TestWarpBreachDuringIncursionCannotBeTriggeredAgain — регрессия: // раньше окунание меча оставалось доступным даже во время уже // идущего вторжения, и если бросок снова достигал порога Скверны, // triggerWarpBreach вызывался повторно поверх уже активного // вторжения, сбрасывая его таймер (ChaosIncursionUntil) и прогресс // защиты (ChaosDefendClicks) игрока. Теперь действие недоступно, пока // идёт вторжение (см. buildActions), так что до самого движка такой // повторный вызов дойти не может — но зафиксируем и в движке, что // вызов triggerWarpBreach во время уже идущего вторжения продлевает // его, а не более тонко сохраняет прогресс, чтобы явно видеть, какое // именно поведение предотвращает интерфейс. func TestWarpBreachDuringIncursionResetsProgressAtEngineLevel(t *testing.T) { g := NewCorpseStarchGame(time.Now()) now := g.LastTick g.ChaosIncursionUntil = now.Add(time.Second) // вторжение вот-вот закончится g.ChaosDefendClicks = 5 // накопленный прогресс защиты g.triggerWarpBreach(now) if g.ChaosDefendClicks != 0 { t.Errorf("на уровне движка triggerWarpBreach действительно обнуляет прогресс защиты (%d) — поэтому интерфейс и не должен допускать повторный вызов во время вторжения", g.ChaosDefendClicks) } if !g.ChaosIncursionUntil.After(now.Add(time.Second)) { t.Errorf("на уровне движка повторный вызов продлевает вторжение заново — поэтому интерфейс блокирует окунание меча, пока вторжение идёт") } } func TestCorpseStarchModelBuyServitorActionHiddenDuringIncursion(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.TotalEarned = corpseStarchUnlockServitors // открываем раздел сервиторов m.game.ChaosIncursionUntil = m.game.LastTick.Add(time.Hour) during := m.buildActions(m.game.LastTick) for _, a := range during { if strings.Contains(a.Label, T("corpsestarch.servitor.skull")) { t.Errorf("покупка сервитора не должна предлагаться во время вторжения") } } } func TestCorpseStarchModelDefendActionAppearsDuringIncursion(t *testing.T) { withTempConfigDir(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) { withTempConfigDir(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) { withTempConfigDir(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() normalized := strings.Join(strings.Fields(view), " ") wantLog := strings.Join(strings.Fields(T("corpsestarch.log.warp_breach")), " ") if !strings.Contains(normalized, wantLog) { 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() if len(m.buildActions(time.Now())) != 1 { t.Fatalf("в начале должно быть только 1 действие (реквизиция)") } m.game.TotalEarned = corpseStarchUnlockServitors if len(m.buildActions(time.Now())) != 2 { t.Fatalf("после открытия сервиторов должно быть 2 действия") } m.game.TotalEarned = corpseStarchUnlockMissions actions := m.buildActions(time.Now()) if len(actions) != 8 { t.Fatalf("ожидалось 8 действий при полностью открытых (кроме 3 тира) разделах, получено %d", len(actions)) } } func TestCorpseStarchModelBuyServitorViaKey(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.TotalEarned = corpseStarchUnlockServitors m.game.Starch = 100 next, _ := m.Update(key("2")) m = next.(CorpseStarchModel) if m.game.ServitorCounts[0] != 1 { t.Fatalf("ожидалась покупка сервочерпа, получено count=%d", m.game.ServitorCounts[0]) } } func TestCorpseStarchModelTickAccruesIncome(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.ServitorCounts[0] = 10 past := m.game.LastTick.Add(2 * time.Second) next, _ := m.Update(corpseStarchTickMsg{At: past}) m = next.(CorpseStarchModel) if m.game.Starch != 10 { t.Errorf("ожидался запас 10 после тика на 2 секунды при 5/сек, получено %v", m.game.Starch) } } func TestCorpseStarchModelResetRequestShowsConfirmation(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) if !m.confirmingReset { t.Fatalf("ожидался запрос подтверждения после нажатия N") } } func TestCorpseStarchModelResetCancelKeepsProgress(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.Starch = 500 m.game.TotalEarned = 500 next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) next, _ = m.Update(key("n")) m = next.(CorpseStarchModel) if m.confirmingReset { t.Errorf("запрос подтверждения должен был закрыться после отмены") } if m.game.Starch != 500 { t.Errorf("прогресс не должен был сброситься при отмене, получено Starch=%v", m.game.Starch) } } func TestCorpseStarchModelResetConfirmWipesProgress(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.Starch = 500 m.game.TotalEarned = 500 next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) next, _ = m.Update(key("y")) m = next.(CorpseStarchModel) if m.confirmingReset { t.Errorf("запрос подтверждения должен был закрыться после подтверждения") } if m.game.Starch != 0 || m.game.TotalEarned != 0 { t.Errorf("прогресс должен был сброситься, получено Starch=%v TotalEarned=%v", m.game.Starch, m.game.TotalEarned) } } func TestCorpseStarchModelChaosOfferAcceptStartsRebirth(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.ChaosMarks = corpseStarchChaosMarksForOffer view := m.View() if !strings.Contains(view, T("corpsestarch.chaos.offer.title")) { t.Fatalf("экран должен был показать предложение Хаоса") } next, _ := m.Update(key("y")) m = next.(CorpseStarchModel) if !m.game.InRebirth(m.game.LastTick) && m.game.RebirthUntil.IsZero() { t.Errorf("принятие предложения должно было начать перерождение") } } func TestCorpseStarchModelChaosOfferDeclineResetsMarks(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.ChaosMarks = corpseStarchChaosMarksForOffer starchBefore := m.game.Starch next, _ := m.Update(key("n")) m = next.(CorpseStarchModel) if m.game.ChaosMarks != 0 { t.Errorf("отказ должен был сбросить счётчик меток, получено %d", m.game.ChaosMarks) } if m.game.Starch <= starchBefore { t.Errorf("отказ должен был дать награду, было %v, стало %v", starchBefore, m.game.Starch) } if m.game.ChaosOfferPending() { t.Errorf("предложение не должно оставаться активным после отказа") } } func TestCorpseStarchModelActionsIgnoredDuringChaosOffer(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.ChaosMarks = corpseStarchChaosMarksForOffer starchBefore := m.game.Starch next, _ := m.Update(key("1")) // обычная реквизиция не должна сработать m = next.(CorpseStarchModel) if m.game.Starch != starchBefore { t.Errorf("обычные действия не должны срабатывать, пока предложение Хаоса не разрешено") } } func TestCorpseStarchModelRebirthShowsHintForQuitAndNewGame(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.RebirthUntil = m.game.LastTick.Add(time.Hour) view := m.View() if !strings.Contains(view, "[q]") || !strings.Contains(view, "[N]") { t.Errorf("экран перерождения должен был показывать подсказки [q] и [N], получено:\n%s", view) } } func TestCorpseStarchModelNKeyDuringRebirthOpensConfirmation(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.RebirthUntil = m.game.LastTick.Add(time.Hour) next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) if !m.confirmingReset { t.Fatalf("клавиша N во время перерождения должна была открыть подтверждение сброса") } view := m.View() if !strings.Contains(view, T("corpsestarch.confirm_reset")) { t.Errorf("экран подтверждения должен был показаться поверх экрана перерождения") } } func TestCorpseStarchModelConfirmNewGameDuringRebirthActuallyResets(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.RebirthUntil = m.game.LastTick.Add(time.Hour) m.game.Starch = 999 next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) next, _ = m.Update(key("y")) m = next.(CorpseStarchModel) if m.confirmingReset { t.Errorf("подтверждение должно было закрыться после ответа") } if m.game.InRebirth(time.Now()) { t.Errorf("новая партия не должна была унаследовать перерождение старой") } if m.game.Starch != 0 { t.Errorf("новая партия должна была начаться с чистого счёта, получено %v", m.game.Starch) } } func TestCorpseStarchModelRebirthBlocksAllActionsExceptQuit(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.RebirthUntil = m.game.LastTick.Add(time.Hour) starchBefore := m.game.Starch next, _ := m.Update(key("1")) m = next.(CorpseStarchModel) if m.game.Starch != starchBefore { t.Errorf("во время перерождения обычные действия не должны срабатывать") } view := m.View() if !strings.Contains(view, "ПЕРЕРОЖДЕНИЕ") { t.Errorf("экран должен был показать статус перерождения") } next, _ = m.Update(key("q")) m = next.(CorpseStarchModel) if !m.backToMenu { t.Errorf("клавиша q должна была сработать даже во время перерождения (сохранение и выход)") } } func TestCorpseStarchModelResetActionsIgnoredWhileConfirming(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() starchBefore := m.game.Starch next, _ := m.Update(key("N")) m = next.(CorpseStarchModel) // нажатие "1" (реквизиция) не должно срабатывать, пока ждём подтверждения next, _ = m.Update(key("1")) m = next.(CorpseStarchModel) if m.game.Starch != starchBefore { t.Errorf("обычные действия не должны выполняться во время запроса подтверждения") } } func TestCorpseStarchModelQReturnsToMenu(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() next, _ := m.Update(key("q")) m = next.(CorpseStarchModel) if !m.backToMenu { t.Fatalf("ожидался флаг backToMenu после 'q'") } if m.quitting { t.Fatalf("'q' не должен закрывать всё приложение") } } func TestCorpseStarchModelMissionFlowViaKeys(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.TotalEarned = corpseStarchUnlockMissions m.game.Starch = 0 actions := m.buildActions(time.Now()) missionActionIdx := -1 for i, a := range actions { if a.Label == Tf("corpsestarch.action.start_mission", T("corpsestarch.mission.vermin"), formatDuration(corpseStarchMissionDefs[0].Duration)) { missionActionIdx = i break } } if missionActionIdx == -1 { t.Fatalf("не нашли действие запуска первой миссии среди %d действий", len(actions)) } next, _ := m.Update(key(string(rune('1' + missionActionIdx)))) m = next.(CorpseStarchModel) if m.game.ActiveMission == nil { t.Fatalf("миссия должна была начаться") } finished := m.game.ActiveMission.StartedAt.Add(corpseStarchMissionDefs[0].Duration + time.Second) if m.game.MissionRemaining(finished) != 0 { t.Fatalf("миссия должна была завершиться к этому моменту") } collectActions := m.buildActions(finished) collectIdx := -1 for i, a := range collectActions { if a.Label == T("corpsestarch.action.collect_mission") { collectIdx = i break } } if collectIdx == -1 { t.Fatalf("не нашли действие сбора награды") } collectActions[collectIdx].Handle(&m, finished) if m.game.ActiveMission != nil { t.Errorf("миссия должна была завершиться после сбора награды") } if m.game.MissionsDone != 1 { t.Errorf("счётчик завершённых миссий должен был увеличиться") } } // TestCorpseStarchDemonicModeNeverMentionsCorpseStarchOrMunitorum — // регрессия: после перехода на сторону Хаоса часть сообщений // (реквизиция, нехватка средств, окунание меча, приветствие после // возвращения, заголовок журнала) была жёстко зашита на имперскую // формулировку "трупный крахмал"/"Муниторум" и не переключалась // вместе с остальной игрой (сервиторы и миссии уже переключались // корректно). func TestCorpseStarchDemonicModeNeverMentionsCorpseStarchOrMunitorum(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() m.game.Mode = CorpseStarchModeDemonic m.game.TotalEarned = corpseStarchUnlockSump m.game.Starch = 1000 now := m.game.LastTick // реально прогоняем игровую логику в демоническом режиме, а не // подставляем старые ключи лога вручную — сами ключи лога // выбираются один раз, в момент события (см. modeKey), а не // задним числом при отрисовке for i := 0; i < 3; i++ { m.game.DipChainsword(now, 0.95) // >=0.9 — гарантированная Скверна на каждом броске } m.game.ChaosDefendClicks = 0 m.game.ChaosIncursionUntil = now.Add(time.Second) m.game.resolveChaosIncursion() // без бонуса (мало кликов защиты) m.game.ChaosDefendClicks = corpseStarchDefendClicksForBonus m.game.ChaosIncursionUntil = now.Add(time.Second) m.game.resolveChaosIncursion() // с бонусом forbidden := []string{"трупн", "Муниторум", "сервитор"} checkNoForbidden := func(label string, text string) { t.Helper() for _, f := range forbidden { if strings.Contains(text, f) { t.Errorf("%s: в демоническом режиме не должно встречаться %q, получено: %q", label, f, text) } } } // заголовок игры ("ТРУПНЫЕ БАТОНЧИКИ") — имя самой игры, не // переключается по режиму и намеренно не проверяется здесь checkNoForbidden("заголовок журнала", T(m.game.modeKey("corpsestarch.log.title", "corpsestarch.log.title.demonic"))) for _, key := range m.game.Log { checkNoForbidden("запись журнала "+key, T(key)) } actions := m.buildActions(now) for _, a := range actions { checkNoForbidden("метка действия "+a.Label, a.Label) } m.game.Requisition(now) m.setInfo(m.game.modeKey("corpsestarch.msg.requisitioned", "corpsestarch.msg.requisitioned.demonic")) checkNoForbidden("сообщение после реквизиции", m.message) m.setInfo(m.game.modeKey("corpsestarch.msg.cannot_afford", "corpsestarch.msg.cannot_afford.demonic")) checkNoForbidden("сообщение о нехватке средств", m.message) m.message = Tf(m.game.modeKey("corpsestarch.msg.welcome_back", "corpsestarch.msg.welcome_back.demonic"), formatStarch(100)) checkNoForbidden("приветственное сообщение", m.message) } func TestCorpseStarchImperialModeStillUsesOriginalWording(t *testing.T) { withTempConfigDir(t) m := NewCorpseStarchModel() // режим по умолчанию — имперский; проверяем, что оригинальная // формулировка никуда не делась для тех, кто ещё не перешёл на // сторону Хаоса view := m.View() if !strings.Contains(view, "трупный крахмал") && !strings.Contains(view, "Трупный крахмал") { t.Errorf("в имперском режиме заголовок должен был по-прежнему упоминать трупный крахмал, получено:\n%s", view) } }