This commit is contained in:
Magnus Root 2026-07-12 13:49:34 +03:00
parent 9353ab527a
commit ceb9bbc37a
7 changed files with 108 additions and 10 deletions

View file

@ -110,7 +110,7 @@ func (m BlackjackModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
if m.game.Phase == BJPhaseRoundOver {
if key == "n" {
if key == "n" && !m.humanIsBankrupt() {
m.game = m.game.NextRound()
m.message = ""
m.isError = false
@ -213,6 +213,13 @@ func (m BlackjackModel) renderDealer() string {
return Tf("blackjack.hand_with_total", renderHandCards(m.game.Dealer), handTotal(m.game.Dealer))
}
func (m BlackjackModel) humanIsBankrupt() bool {
if m.humanIdx < 0 || m.humanIdx >= len(m.game.Players) {
return false
}
return m.game.Players[m.humanIdx].Balance == 0
}
func (m BlackjackModel) View() string {
if m.quitting {
return T("common.goodbye")
@ -228,7 +235,13 @@ func (m BlackjackModel) View() string {
if m.game.Phase == BJPhaseRoundOver {
fmt.Fprintln(&b, m.renderResult())
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("blackjack.hint.gameover")))
if m.humanIsBankrupt() {
fmt.Fprintln(&b, errorStyle.Render(T("blackjack.bankrupt")))
fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T("blackjack.hint.bankrupt")))
} else {
fmt.Fprintln(&b, dimStyle.Render(T("blackjack.hint.gameover")))
}
return b.String()
}

View file

@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
func TestBlackjackModelDealsCorrectPlayerCount(t *testing.T) {
for n := 1; n <= 6; n++ {
@ -179,7 +182,7 @@ func TestBlackjackModelShowsMessageWhenBetReduced(t *testing.T) {
}
}
func TestBlackjackModelShowsMessageWhenNoFundsLeft(t *testing.T) {
func TestBlackjackModelBankruptBlocksNextRoundAndShowsMessage(t *testing.T) {
m := NewBlackjackModel(1, 100, 10)
for m.game.Phase == BJPhasePlayerTurn {
if err := m.game.Stand(); err != nil {
@ -187,13 +190,22 @@ func TestBlackjackModelShowsMessageWhenNoFundsLeft(t *testing.T) {
}
}
m.game.Players[m.humanIdx].Balance = 0
handsBefore := len(m.game.Players[m.humanIdx].Hand)
if !m.humanIsBankrupt() {
t.Fatalf("ожидалось, что humanIsBankrupt() вернёт true при нулевом балансе")
}
view := m.View()
if !strings.Contains(view, T("blackjack.bankrupt")) {
t.Errorf("экран должен был показать сообщение о банкротстве, получено:\n%s", view)
}
next, _ := m.Update(key("n"))
m = next.(BlackjackModel)
if m.game.Players[m.humanIdx].Bet != 0 {
t.Errorf("ставка при отсутствии денег должна была стать 0, получено %d", m.game.Players[m.humanIdx].Bet)
if m.game.Phase != BJPhaseRoundOver {
t.Errorf("нажатие 'n' при банкротстве не должно было начинать новый раунд")
}
if m.message == "" {
t.Errorf("ожидалось информационное сообщение об отсутствии денег")
if len(m.game.Players[m.humanIdx].Hand) != handsBefore {
t.Errorf("рука не должна была измениться — новый раунд не должен был начаться")
}
}

View file

@ -125,7 +125,7 @@ func (m CorpseStarchModel) buildActions(now time.Time) []corpseStarchAction {
})
}
if g.SumpUnlocked() {
if g.SumpUnlocked() && !g.InChaosIncursion(now) {
actions = append(actions, corpseStarchAction{
Label: T("corpsestarch.action.dip"),
Handle: func(m *CorpseStarchModel, now time.Time) {
@ -139,6 +139,15 @@ func (m CorpseStarchModel) buildActions(now time.Time) []corpseStarchAction {
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")

View file

@ -17,6 +17,66 @@ func TestCorpseStarchModelRequisitionViaKey(t *testing.T) {
}
}
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()

View file

@ -1621,7 +1621,9 @@ func (m AppModel) viewMenu() string {
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
if d := opt.desc(); d != "" {
fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(d))
wrapped := wrapText(d, m.termWidth)
indented := strings.ReplaceAll(wrapped, "\n", "\n ")
fmt.Fprintf(&b, " %s\n", menuDescStyle.Render(indented))
}
}
if end < len(m.options) {

BIN
tonk

Binary file not shown.

View file

@ -336,6 +336,8 @@ func init() {
"blackjack.title": {"=== БЛЭКДЖЕК ===", "=== BLACKJACK ==="},
"blackjack.dealer_line": {"Дилер:\n%s\n\n", "Dealer:\n%s\n\n"},
"blackjack.hint.gameover": {"[n] новый раунд [q] в меню", "[n] new round [q] menu"},
"blackjack.bankrupt": {"Вы банкрот — фишек не осталось.", "You're bankrupt — you have no chips left."},
"blackjack.hint.bankrupt": {"[q] в меню", "[q] menu"},
"blackjack.hint.turn": {"[h] взять карту [s] остановиться [d] удвоить ставку [q] в меню", "[h] hit [s] stand [d] double down [q] menu"},
"blackjack.msg.hit": {"Вы взяли карту.", "You took a card."},
"blackjack.msg.stand": {"Вы остановились.", "You stood."},