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

513 lines
21 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 (
"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)
}
}