Fixed story mod
This commit is contained in:
parent
79e91784d2
commit
3f90ba3220
15 changed files with 874 additions and 111 deletions
|
|
@ -42,11 +42,15 @@ type CheckersModel struct {
|
|||
// значение по умолчанию из CheckersBot).
|
||||
func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel {
|
||||
human := resolveCheckersColor(colorChoice)
|
||||
startRow := 5
|
||||
if human == CheckersBlack {
|
||||
startRow = 2
|
||||
}
|
||||
return CheckersModel{
|
||||
game: NewCheckersGame(),
|
||||
bot: CheckersBot{Depth: depth},
|
||||
human: human,
|
||||
cursor: CheckersPos{Row: 5, Col: 0},
|
||||
cursor: CheckersPos{Row: startRow, Col: 0},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,21 +146,38 @@ func (m CheckersModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
flip := m.human == CheckersBlack
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if m.cursor.Row > 0 {
|
||||
if flip {
|
||||
if m.cursor.Row < 7 {
|
||||
m.cursor.Row++
|
||||
}
|
||||
} else if m.cursor.Row > 0 {
|
||||
m.cursor.Row--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor.Row < 7 {
|
||||
if flip {
|
||||
if m.cursor.Row > 0 {
|
||||
m.cursor.Row--
|
||||
}
|
||||
} else if m.cursor.Row < 7 {
|
||||
m.cursor.Row++
|
||||
}
|
||||
case "left", "h":
|
||||
if m.cursor.Col > 0 {
|
||||
if flip {
|
||||
if m.cursor.Col < 7 {
|
||||
m.cursor.Col++
|
||||
}
|
||||
} else if m.cursor.Col > 0 {
|
||||
m.cursor.Col--
|
||||
}
|
||||
case "right", "l":
|
||||
if m.cursor.Col < 7 {
|
||||
if flip {
|
||||
if m.cursor.Col > 0 {
|
||||
m.cursor.Col--
|
||||
}
|
||||
} else if m.cursor.Col < 7 {
|
||||
m.cursor.Col++
|
||||
}
|
||||
case "esc":
|
||||
|
|
@ -249,11 +270,30 @@ func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool {
|
|||
|
||||
func (m CheckersModel) renderBoard() string {
|
||||
dests := m.legalDestinationSet()
|
||||
flip := m.human == CheckersBlack
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, " a b c d e f g h")
|
||||
for r := 0; r < 8; r++ {
|
||||
|
||||
fmt.Fprint(&b, " ")
|
||||
for cc := 0; cc < 8; cc++ {
|
||||
col := cc
|
||||
if flip {
|
||||
col = 7 - cc
|
||||
}
|
||||
fmt.Fprintf(&b, "%c ", 'a'+col)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
|
||||
for rr := 0; rr < 8; rr++ {
|
||||
r := rr
|
||||
if flip {
|
||||
r = 7 - rr
|
||||
}
|
||||
fmt.Fprintf(&b, "%d ", 8-r)
|
||||
for c := 0; c < 8; c++ {
|
||||
for cc := 0; cc < 8; cc++ {
|
||||
c := cc
|
||||
if flip {
|
||||
c = 7 - cc
|
||||
}
|
||||
pos := CheckersPos{Row: r, Col: c}
|
||||
cellStyle := checkersLightSquare
|
||||
if (r+c)%2 == 1 {
|
||||
|
|
|
|||
|
|
@ -228,3 +228,39 @@ func TestNewCheckersModelRandomColorGivesValidColor(t *testing.T) {
|
|||
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckersBoardNotFlippedForWhite(t *testing.T) {
|
||||
m := NewCheckersModel(0, ColorChoiceA)
|
||||
if m.cursor.Row != 5 {
|
||||
t.Errorf("за белых курсор должен был стартовать в ряду 5 (своя сторона), получено %d", m.cursor.Row)
|
||||
}
|
||||
view := m.renderBoard()
|
||||
lines := strings.Split(view, "\n")
|
||||
if !strings.HasPrefix(lines[8], "1 ") {
|
||||
t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckersBoardFlippedForBlack(t *testing.T) {
|
||||
m := NewCheckersModel(0, ColorChoiceB)
|
||||
if m.cursor.Row != 2 {
|
||||
t.Errorf("за чёрных курсор должен был стартовать в ряду 2 (своя сторона), получено %d", m.cursor.Row)
|
||||
}
|
||||
view := m.renderBoard()
|
||||
lines := strings.Split(view, "\n")
|
||||
if !strings.HasPrefix(lines[8], "8 ") {
|
||||
t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckersCursorMovementFlippedForBlack(t *testing.T) {
|
||||
m := NewCheckersModel(0, ColorChoiceB)
|
||||
m.game.Turn = m.human
|
||||
startRow := m.cursor.Row
|
||||
|
||||
next, _ := m.Update(key("up"))
|
||||
m2 := next.(CheckersModel)
|
||||
if m2.cursor.Row != startRow+1 {
|
||||
t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
chess_tui.go
56
chess_tui.go
|
|
@ -32,11 +32,15 @@ type ChessModel struct {
|
|||
// глубина поиска бота в полуходах (0 -> значение по умолчанию).
|
||||
func NewChessModel(depth int, colorChoice ColorChoice) ChessModel {
|
||||
human := resolveChessColor(colorChoice)
|
||||
startRow := 7
|
||||
if human == ChessBlack {
|
||||
startRow = 0
|
||||
}
|
||||
return ChessModel{
|
||||
game: NewChessGame(),
|
||||
bot: ChessBot{Depth: depth},
|
||||
human: human,
|
||||
cursor: ChessPos{Row: 7, Col: 4},
|
||||
cursor: ChessPos{Row: startRow, Col: 4},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,21 +136,38 @@ func (m ChessModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
flip := m.human == ChessBlack
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if m.cursor.Row > 0 {
|
||||
if flip {
|
||||
if m.cursor.Row < 7 {
|
||||
m.cursor.Row++
|
||||
}
|
||||
} else if m.cursor.Row > 0 {
|
||||
m.cursor.Row--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor.Row < 7 {
|
||||
if flip {
|
||||
if m.cursor.Row > 0 {
|
||||
m.cursor.Row--
|
||||
}
|
||||
} else if m.cursor.Row < 7 {
|
||||
m.cursor.Row++
|
||||
}
|
||||
case "left", "h":
|
||||
if m.cursor.Col > 0 {
|
||||
if flip {
|
||||
if m.cursor.Col < 7 {
|
||||
m.cursor.Col++
|
||||
}
|
||||
} else if m.cursor.Col > 0 {
|
||||
m.cursor.Col--
|
||||
}
|
||||
case "right", "l":
|
||||
if m.cursor.Col < 7 {
|
||||
if flip {
|
||||
if m.cursor.Col > 0 {
|
||||
m.cursor.Col--
|
||||
}
|
||||
} else if m.cursor.Col < 7 {
|
||||
m.cursor.Col++
|
||||
}
|
||||
case "esc":
|
||||
|
|
@ -251,11 +272,30 @@ func (m ChessModel) legalDestinationSet() map[ChessPos]bool {
|
|||
|
||||
func (m ChessModel) renderBoard() string {
|
||||
dests := m.legalDestinationSet()
|
||||
flip := m.human == ChessBlack
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, " a b c d e f g h")
|
||||
for r := 0; r < 8; r++ {
|
||||
|
||||
fmt.Fprint(&b, " ")
|
||||
for cc := 0; cc < 8; cc++ {
|
||||
col := cc
|
||||
if flip {
|
||||
col = 7 - cc
|
||||
}
|
||||
fmt.Fprintf(&b, "%c ", 'a'+col)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
|
||||
for rr := 0; rr < 8; rr++ {
|
||||
r := rr
|
||||
if flip {
|
||||
r = 7 - rr
|
||||
}
|
||||
fmt.Fprintf(&b, "%d ", 8-r)
|
||||
for c := 0; c < 8; c++ {
|
||||
for cc := 0; cc < 8; cc++ {
|
||||
c := cc
|
||||
if flip {
|
||||
c = 7 - cc
|
||||
}
|
||||
pos := ChessPos{Row: r, Col: c}
|
||||
cellStyle := checkersLightSquare
|
||||
if (r+c)%2 == 1 {
|
||||
|
|
|
|||
|
|
@ -252,3 +252,50 @@ func TestNewChessModelRandomColorGivesValidColor(t *testing.T) {
|
|||
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChessBoardNotFlippedForWhite(t *testing.T) {
|
||||
m := NewChessModel(0, ColorChoiceA) // белые
|
||||
if m.cursor.Row != 7 {
|
||||
t.Errorf("за белых курсор должен был стартовать в ряду 7 (своя сторона), получено %d", m.cursor.Row)
|
||||
}
|
||||
view := m.renderBoard()
|
||||
lines := strings.Split(view, "\n")
|
||||
// последняя содержательная строка доски (перед пустой) должна
|
||||
// начинаться с "1" (ряд белых снизу) без разворота
|
||||
if !strings.HasPrefix(lines[8], "1 ") {
|
||||
t.Errorf("за белых нижний ряд доски должен был быть подписан 1, получено: %q", lines[8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChessBoardFlippedForBlack(t *testing.T) {
|
||||
m := NewChessModel(0, ColorChoiceB) // чёрные
|
||||
if m.cursor.Row != 0 {
|
||||
t.Errorf("за чёрных курсор должен был стартовать в ряду 0 (своя сторона), получено %d", m.cursor.Row)
|
||||
}
|
||||
view := m.renderBoard()
|
||||
lines := strings.Split(view, "\n")
|
||||
// доска развёрнута — нижний ряд теперь подписан 8 (ряд чёрных)
|
||||
if !strings.HasPrefix(lines[8], "8 ") {
|
||||
t.Errorf("за чёрных нижний ряд доски должен был быть подписан 8 (развёрнуто), получено: %q", lines[8])
|
||||
}
|
||||
// и заголовок с буквами файлов тоже развёрнут: h идёт первой
|
||||
if !strings.Contains(lines[0], "h") || strings.Index(lines[0], "h") > strings.Index(lines[0], "a") {
|
||||
t.Errorf("за чёрных буквы файлов в заголовке тоже должны были развернуться, получено: %q", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChessCursorMovementFlippedForBlack(t *testing.T) {
|
||||
m := NewChessModel(0, ColorChoiceB)
|
||||
m.game.Turn = m.human // ход человека, чтобы клавиши обрабатывались
|
||||
startRow := m.cursor.Row
|
||||
|
||||
next, _ := m.Update(key("up"))
|
||||
m2 := next.(ChessModel)
|
||||
// на развёрнутой доске "вверх" должно визуально уводить курсор
|
||||
// туда же, куда он был бы направлен на обычной доске — то есть
|
||||
// в сторону дальнего от игрока ряда, что при развороте означает
|
||||
// увеличение Row, а не уменьшение
|
||||
if m2.cursor.Row != startRow+1 {
|
||||
t.Errorf("за чёрных 'вверх' должен был увеличивать Row (развёрнутая доска), получено %d (было %d)", m2.cursor.Row, startRow)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
ggc
(Stored with Git LFS)
BIN
ggc
(Stored with Git LFS)
Binary file not shown.
BIN
ggc.exe
(Stored with Git LFS)
BIN
ggc.exe
(Stored with Git LFS)
Binary file not shown.
167
menu.go
167
menu.go
|
|
@ -234,8 +234,10 @@ const (
|
|||
stateStoryHub
|
||||
stateStoryConditions
|
||||
stateStoryUseItem
|
||||
stateStorySurvivalAttempt
|
||||
stateStoryEscapeAttempt
|
||||
stateStoryEscapeEnding
|
||||
stateStoryTrueEnding
|
||||
stateMenu
|
||||
stateRulesMenu
|
||||
stateRules
|
||||
|
|
@ -764,6 +766,8 @@ type AppModel struct {
|
|||
storyGreeting storyGreetingKind
|
||||
storyPostGameFlavor string
|
||||
storyConfirmingReset bool
|
||||
cheatArmed bool
|
||||
cheatBuffer string
|
||||
storyHubCursor int
|
||||
storyActiveResource *StoryResourceID
|
||||
cursor int
|
||||
|
|
@ -820,6 +824,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
if handled, next, cmd := m.handleStoryCheatCode(msg); handled {
|
||||
return next, cmd
|
||||
}
|
||||
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.termWidth = sizeMsg.Width
|
||||
m.termHeight = sizeMsg.Height
|
||||
|
|
@ -862,10 +869,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.updateStoryConditions(msg)
|
||||
case stateStoryUseItem:
|
||||
return m.updateStoryUseItem(msg)
|
||||
case stateStorySurvivalAttempt:
|
||||
return m.updateStorySurvivalAttempt(msg)
|
||||
case stateStoryEscapeAttempt:
|
||||
return m.updateStoryEscapeAttempt(msg)
|
||||
case stateStoryEscapeEnding:
|
||||
return m.updateStoryEscapeEnding(msg)
|
||||
case stateStoryTrueEnding:
|
||||
return m.updateStoryTrueEnding(msg)
|
||||
case stateRulesMenu:
|
||||
return m.updateRulesMenu(msg)
|
||||
case stateRules:
|
||||
|
|
@ -1611,36 +1622,12 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if backToMenu {
|
||||
if m.storyActiveResource != nil {
|
||||
id := *m.storyActiveResource
|
||||
won := false
|
||||
if def, ok := storyGameDefs[id]; ok {
|
||||
if finished, won := def.Outcome(m.activeGame); finished && won {
|
||||
if id == StoryResKlondike {
|
||||
// у Косынки особое правило — 3 победы за
|
||||
// один день, а не начисление на каждую;
|
||||
// суточный лимит начислений (AwardResource)
|
||||
// проверяется уже поверх этого правила
|
||||
m.story.KlondikeWinsToday++
|
||||
if m.story.KlondikeWinsToday == 3 {
|
||||
m.story.AwardResource(id)
|
||||
}
|
||||
} else {
|
||||
m.story.AwardResource(id)
|
||||
}
|
||||
}
|
||||
finished, w := def.Outcome(m.activeGame)
|
||||
won = finished && w
|
||||
}
|
||||
m.storyActiveResource = nil
|
||||
m.activeGame = nil
|
||||
if m.story.AdvanceOnboarding() {
|
||||
// именно этим исходом партии знакомство завершилось —
|
||||
// показываем приветствие один-единственный раз, и уже
|
||||
// после него откроется обычный лагерь целиком
|
||||
_ = SaveStoryModeState(m.story)
|
||||
m.state = stateStoryWelcome
|
||||
m.storyWelcomeWordsShown = 0
|
||||
return m, storyWelcomeTick()
|
||||
}
|
||||
m.storyPostGameFlavor = randomStoryPostGameFlavor()
|
||||
m = m.enterStoryHub()
|
||||
return m, nil
|
||||
return m.storyProcessGameOutcome(id, won)
|
||||
}
|
||||
m.state = stateMenu
|
||||
m.activeGame = nil
|
||||
|
|
@ -1649,6 +1636,94 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
// storyProcessGameOutcome — начисляет ресурс (если won) за только
|
||||
// что законченную партию сторимода и продвигает знакомство, если
|
||||
// применимо. Общий код для обычного возврата из партии и для
|
||||
// чит-кода принудительной победы (см. handleStoryCheatCode) — оба
|
||||
// должны вести себя одинаково.
|
||||
func (m AppModel) storyProcessGameOutcome(id StoryResourceID, won bool) (tea.Model, tea.Cmd) {
|
||||
if won {
|
||||
if id == StoryResKlondike {
|
||||
// у Косынки особое правило — 3 победы за один день, а не
|
||||
// начисление на каждую; суточный лимит начислений
|
||||
// (AwardResource) проверяется уже поверх этого правила
|
||||
m.story.KlondikeWinsToday++
|
||||
if m.story.KlondikeWinsToday == 3 {
|
||||
m.story.AwardResource(id)
|
||||
}
|
||||
} else {
|
||||
m.story.AwardResource(id)
|
||||
}
|
||||
}
|
||||
m.storyActiveResource = nil
|
||||
m.activeGame = nil
|
||||
if m.story.AdvanceOnboarding() {
|
||||
// именно этим исходом партии знакомство завершилось —
|
||||
// показываем приветствие один-единственный раз, и уже
|
||||
// после него откроется обычный бункер целиком
|
||||
_ = SaveStoryModeState(m.story)
|
||||
m.state = stateStoryWelcome
|
||||
m.storyWelcomeWordsShown = 0
|
||||
return m, storyWelcomeTick()
|
||||
}
|
||||
if m.story.Complete() {
|
||||
// все 15 ресурсов заперты — истинная концовка; сохранение
|
||||
// удалится при выходе с этого экрана (см. updateStoryTrueEnding)
|
||||
_ = SaveStoryModeState(m.story)
|
||||
m.state = stateStoryTrueEnding
|
||||
return m, nil
|
||||
}
|
||||
m.storyPostGameFlavor = randomStoryPostGameFlavor()
|
||||
m = m.enterStoryHub()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleStoryCheatCode — читкод для тестирования: во время активной
|
||||
// партии сторимода (statePlaying, есть привязанный storyActiveResource)
|
||||
// двоеточие вооружает ввод, дальше три "7" подряд засчитывают победу
|
||||
// в ТЕКУЩЕЙ игре, как если бы она реально была выиграна. Любая другая
|
||||
// клавиша во время вооружённого ввода отменяет попытку. Возвращает
|
||||
// handled=true, если клавиша была перехвачена читом (и тогда её не
|
||||
// нужно передавать дальше по обычной цепочке обработки).
|
||||
func (m AppModel) handleStoryCheatCode(msg tea.Msg) (handled bool, next tea.Model, cmd tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return false, m, nil
|
||||
}
|
||||
key := keyMsg.String()
|
||||
|
||||
if !m.cheatArmed {
|
||||
if key == ":" && m.state == statePlaying && m.storyActiveResource != nil {
|
||||
m.cheatArmed = true
|
||||
m.cheatBuffer = ""
|
||||
return true, m, nil
|
||||
}
|
||||
return false, m, nil
|
||||
}
|
||||
|
||||
// уже вооружено — эта клавиша так или иначе про чит-код, наружу
|
||||
// (в текущую игру) её не пускаем
|
||||
switch key {
|
||||
case ":":
|
||||
m.cheatBuffer = "" // начать ввод заново
|
||||
return true, m, nil
|
||||
case "7":
|
||||
m.cheatBuffer += "7"
|
||||
if m.cheatBuffer != "777" {
|
||||
return true, m, nil
|
||||
}
|
||||
m.cheatArmed = false
|
||||
m.cheatBuffer = ""
|
||||
id := *m.storyActiveResource
|
||||
next, cmd := m.storyProcessGameOutcome(id, true)
|
||||
return true, next, cmd
|
||||
default:
|
||||
m.cheatArmed = false
|
||||
m.cheatBuffer = ""
|
||||
return true, m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m AppModel) View() string {
|
||||
if m.quitting {
|
||||
return T("common.goodbye")
|
||||
|
|
@ -1676,10 +1751,14 @@ func (m AppModel) View() string {
|
|||
return m.viewStoryConditions()
|
||||
case stateStoryUseItem:
|
||||
return m.viewStoryUseItem()
|
||||
case stateStorySurvivalAttempt:
|
||||
return m.viewStorySurvivalAttempt()
|
||||
case stateStoryEscapeAttempt:
|
||||
return m.viewStoryEscapeAttempt()
|
||||
case stateStoryEscapeEnding:
|
||||
return m.viewStoryEscapeEnding()
|
||||
case stateStoryTrueEnding:
|
||||
return m.viewStoryTrueEnding()
|
||||
case stateRulesMenu:
|
||||
return m.viewRulesMenu()
|
||||
case stateRules:
|
||||
|
|
@ -1727,7 +1806,7 @@ func (m AppModel) viewLanguage() string {
|
|||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("lang.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("lang.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1747,7 +1826,7 @@ func (m AppModel) viewModeSelect() string {
|
|||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1791,7 +1870,7 @@ func (m AppModel) viewMenu() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("menu.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("menu.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1825,7 +1904,7 @@ func (m AppModel) viewRulesMenu() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("rulesmenu.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("rulesmenu.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1851,7 +1930,7 @@ func (m AppModel) viewRules() string {
|
|||
if maxScroll > 0 {
|
||||
scrollInfo = " " + Tf("rules.scroll_info", start+1, end, len(lines))
|
||||
}
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("rules.hint")+scrollInfo))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("rules.hint")+scrollInfo, m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1886,7 +1965,7 @@ func (m AppModel) viewSettings() string {
|
|||
T("stake.pot_info"), pot, T("stake.tonk_win"), m.settings.multiplier(), pot*m.settings.multiplier())))
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("stake.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("stake.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1899,7 +1978,7 @@ func (m AppModel) viewPlayerCount() string {
|
|||
fmt.Fprintln(&b)
|
||||
fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("playercount.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("playercount.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1936,7 +2015,7 @@ func (m AppModel) viewBlackjackSettings() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("bjsettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("bjsettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1964,7 +2043,7 @@ func (m AppModel) viewCheckersSettings() string {
|
|||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -1985,7 +2064,7 @@ func (m AppModel) viewTicTacToeSettings() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("checkerslvl.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("checkerslvl.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2017,7 +2096,7 @@ func (m AppModel) viewNardySettings() string {
|
|||
desc := nardyDifficultyLevels[m.nardy.difficultyIdx].DescKey
|
||||
fmt.Fprintln(&b, dimStyle.Render(T(desc)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2045,7 +2124,7 @@ func (m AppModel) viewUrSettings() string {
|
|||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2073,7 +2152,7 @@ func (m AppModel) viewSenetSettings() string {
|
|||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2101,7 +2180,7 @@ func (m AppModel) viewChessSettings() string {
|
|||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2130,7 +2209,7 @@ func (m AppModel) viewGoSettings() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("gosettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("gosettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -2151,6 +2230,6 @@ func (m AppModel) viewMinesweeperSettings() string {
|
|||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("mssettings.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("mssettings.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1714,4 +1714,4 @@ func TestLowercaseQDoesNotQuitAppFromModeSelect(t *testing.T) {
|
|||
if m.state != stateLanguage {
|
||||
t.Errorf("строчная q должна была вернуть на экран выбора языка, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,7 +284,11 @@ func (m NardyModel) View() string {
|
|||
nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack]))
|
||||
fmt.Fprintln(&b)
|
||||
|
||||
you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor))
|
||||
colorWord := T("color.white")
|
||||
if m.humanColor == NardyBlack {
|
||||
colorWord = T("color.black")
|
||||
}
|
||||
you := Tf("nardy.you_are", nardyPlayerSymbol(m.humanColor)+" "+colorWord)
|
||||
fmt.Fprintln(&b, dimStyle.Render(you))
|
||||
|
||||
switch m.game.Phase {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -287,3 +288,17 @@ func TestNewNardyModelRandomColorGivesValidColor(t *testing.T) {
|
|||
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNardyViewNamesPlayerColorInWords(t *testing.T) {
|
||||
mWhite := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceA)
|
||||
view := mWhite.View()
|
||||
if !strings.Contains(view, T("color.white")) {
|
||||
t.Errorf("за белых в подписи должно было явно упоминаться слово 'Белые', получено:\n%s", view)
|
||||
}
|
||||
|
||||
mBlack := NewNardyModel(NardyShort, NardyDifficultyEasy, ColorChoiceB)
|
||||
view = mBlack.View()
|
||||
if !strings.Contains(view, T("color.black")) {
|
||||
t.Errorf("за чёрных в подписи должно было явно упоминаться слово 'Чёрные', получено:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1222,4 +1222,369 @@ func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) {
|
|||
if m.state != stateStoryHub {
|
||||
t.Errorf("ожидался переход в хаб, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit — регрессия:
|
||||
// раньше суточный лимит начислений (StoryDailyGrantsLimit = 3)
|
||||
// применялся и во время знакомства, а оно требует 4 начисления
|
||||
// подряд (3 победы в крестики-нолики + 1 в шашки) — если сыграть всё
|
||||
// это за один день, четвёртое начисление (победа в шашках) просто не
|
||||
// проходило, и знакомство зависало навсегда на этапе "+ Шашки".
|
||||
func TestOnboardingCompletesInOneDayDespiteDailyGrantsLimit(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.state = stateStoryHub
|
||||
|
||||
ttt := NewTicTacToeModel(TicTacToeDifficultyEasy)
|
||||
ttt.game.Phase = TicTacToePhaseOver
|
||||
ttt.game.Result = &TicTacToeResult{Winner: ttt.game.HumanMark}
|
||||
ttt.backToMenu = true
|
||||
for i := 0; i < 3; i++ {
|
||||
resource := StoryResTicTacToe
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = ttt
|
||||
m.state = statePlaying
|
||||
next, _ := m.Update(key("q"))
|
||||
m = next.(AppModel)
|
||||
}
|
||||
if m.story.Onboarding != StoryOnboardingWithCheckers {
|
||||
t.Fatalf("после 3 побед в крестики-нолики ожидался переход на этап WithCheckers, получено %v", m.story.Onboarding)
|
||||
}
|
||||
if m.story.DailyGrantsUsed[StoryResTicTacToe] != StoryDailyGrantsLimit {
|
||||
t.Fatalf("три победы должны были исчерпать суточный лимит именно крестиков-ноликов (%d), получено %d", StoryDailyGrantsLimit, m.story.DailyGrantsUsed[StoryResTicTacToe])
|
||||
}
|
||||
|
||||
cm := NewCheckersModel(0, ColorChoiceA)
|
||||
cm.game.Result = &CheckersResult{Winner: cm.human}
|
||||
cm.backToMenu = true
|
||||
resource := StoryResCheckers
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = cm
|
||||
m.state = statePlaying
|
||||
next, _ := m.Update(key("q"))
|
||||
m = next.(AppModel)
|
||||
|
||||
if m.story.Onboarding != StoryOnboardingDone {
|
||||
t.Errorf("победа в шашках в тот же день должна была завершить знакомство несмотря на исчерпанный суточный лимит, получено %v", m.story.Onboarding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatCodeArmsOnlyDuringActiveStoryGame(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.state = stateStoryHub // не во время партии
|
||||
|
||||
next, _ := m.Update(key(":"))
|
||||
m = next.(AppModel)
|
||||
if m.cheatArmed {
|
||||
t.Errorf("двоеточие вне активной партии не должно было вооружить чит-код")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatCodeTripleSevenForcesWin(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
before := m.story.Resources[StoryResChess]
|
||||
|
||||
resource := StoryResChess
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = NewChessModel(0, ColorChoiceA) // партия и близко не завершена
|
||||
m.state = statePlaying
|
||||
|
||||
next, _ := m.Update(key(":"))
|
||||
m = next.(AppModel)
|
||||
if !m.cheatArmed {
|
||||
t.Fatalf("двоеточие во время активной партии сторимода должно было вооружить чит-код")
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
next, _ = m.Update(key("7"))
|
||||
m = next.(AppModel)
|
||||
}
|
||||
|
||||
if m.cheatArmed {
|
||||
t.Errorf("после ввода 777 чит-код должен был сработать и разоружиться")
|
||||
}
|
||||
if m.state != stateStoryHub {
|
||||
t.Fatalf("после чит-кода ожидался возврат в хаб, получено %v", m.state)
|
||||
}
|
||||
if m.story.Resources[StoryResChess] != before+1 {
|
||||
t.Errorf("чит-код должен был засчитать победу в шахматах, получено %d (было %d)", m.story.Resources[StoryResChess], before)
|
||||
}
|
||||
if m.storyActiveResource != nil {
|
||||
t.Errorf("отметка активного ресурса должна была сброситься после чит-кода")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatCodeWrongDigitCancels(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
before := m.story.Resources[StoryResChess]
|
||||
|
||||
resource := StoryResChess
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = NewChessModel(0, ColorChoiceA)
|
||||
m.state = statePlaying
|
||||
|
||||
next, _ := m.Update(key(":"))
|
||||
m = next.(AppModel)
|
||||
next, _ = m.Update(key("7"))
|
||||
m = next.(AppModel)
|
||||
next, _ = m.Update(key("8")) // не та цифра — отменяет попытку
|
||||
m = next.(AppModel)
|
||||
|
||||
if m.cheatArmed {
|
||||
t.Errorf("неверная цифра должна была отменить вооружённый чит-код")
|
||||
}
|
||||
if m.story.Resources[StoryResChess] != before {
|
||||
t.Errorf("отменённая попытка не должна была засчитать победу, получено %d (было %d)", m.story.Resources[StoryResChess], before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheatCodeOnboardingAlsoAdvances(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingWithCheckers
|
||||
m.story.Resources[StoryResTicTacToe] = storyStartingAmount + StoryOnboardingTicTacToeWinsNeeded
|
||||
|
||||
resource := StoryResCheckers
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = NewCheckersModel(0, ColorChoiceA)
|
||||
m.state = statePlaying
|
||||
|
||||
next, _ := m.Update(key(":"))
|
||||
m = next.(AppModel)
|
||||
for i := 0; i < 3; i++ {
|
||||
next, _ = m.Update(key("7"))
|
||||
m = next.(AppModel)
|
||||
}
|
||||
|
||||
if m.story.Onboarding != StoryOnboardingDone {
|
||||
t.Errorf("чит-код тоже должен был продвинуть знакомство, получено %v", m.story.Onboarding)
|
||||
}
|
||||
if m.state != stateStoryWelcome {
|
||||
t.Errorf("завершение знакомства через чит-код тоже должно было показать приветствие, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvivalEndingRequiresAllSevenSpecificResources(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
|
||||
// запираем 7 ЛЮБЫХ ресурсов, но не тех самых семи — не должно сработать
|
||||
m.story.Locked[StoryResNardy] = true
|
||||
m.story.Locked[StoryResBlackjack] = true
|
||||
m.story.Locked[StoryResUr] = true
|
||||
m.story.Locked[StoryResSenet] = true
|
||||
m.story.Locked[StoryResOneOhOne] = true
|
||||
m.story.Locked[StoryResThousand] = true
|
||||
m.story.Locked[StoryResTicTacToe] = true
|
||||
if m.storySurvivalEndingUnlocked() {
|
||||
t.Fatalf("секретная концовка не должна была разблокироваться от произвольных 7 ресурсов")
|
||||
}
|
||||
|
||||
// теперь запираем именно нужные семь
|
||||
for _, id := range storySurvivalResources {
|
||||
m.story.Locked[id] = true
|
||||
}
|
||||
if !m.storySurvivalEndingUnlocked() {
|
||||
t.Errorf("секретная концовка должна была разблокироваться при запертых Тонк/Шашки/Шахматы/Сапёр/Го/Дурак/Косынка")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvivalOptionAppearsInHubAndLeadsToAttemptScreen(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
for _, id := range storySurvivalResources {
|
||||
m.story.Locked[id] = true
|
||||
}
|
||||
m.state = stateStoryHub
|
||||
m.storyHubCursor = len(storyHubOrder) // единственный дополнительный пункт пока
|
||||
|
||||
view := m.View()
|
||||
if !strings.Contains(view, T("story.hub.survival_option")) {
|
||||
t.Fatalf("пункт 'Выйти из бункера' должен был появиться в хабе")
|
||||
}
|
||||
|
||||
next, _ := m.Update(key("enter"))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateStorySurvivalAttempt {
|
||||
t.Fatalf("выбор пункта должен был вести на экран уговоров остаться, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvivalAttemptStayingReturnsToHub(t *testing.T) {
|
||||
m := AppModel{state: stateStorySurvivalAttempt}
|
||||
next, _ := m.Update(key("1"))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateStoryHub {
|
||||
t.Errorf("выбор 'остаться' должен был вернуть в хаб, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvivalAttemptLeavingGoesToEnding(t *testing.T) {
|
||||
m := AppModel{state: stateStorySurvivalAttempt}
|
||||
next, _ := m.Update(key("2"))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateStoryEscapeEnding {
|
||||
t.Errorf("выбор 'выйти' должен был вести на экран концовки, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothSecretEndingsCanAppearTogether(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
for _, id := range storySurvivalResources {
|
||||
m.story.Locked[id] = true
|
||||
}
|
||||
m.story.Resources[StoryResTicTacToe] = 50
|
||||
m.story.Resources[StoryResSenet] = 50
|
||||
m.story.Stress = 60
|
||||
m.state = stateStoryHub
|
||||
|
||||
extras := m.storyExtraHubItems()
|
||||
if len(extras) != 2 {
|
||||
t.Fatalf("ожидалось 2 дополнительных пункта одновременно, получено %d", len(extras))
|
||||
}
|
||||
if extras[0].State != stateStorySurvivalAttempt {
|
||||
t.Errorf("первым должен был идти пункт выживания, получено %v", extras[0].State)
|
||||
}
|
||||
if extras[1].State != stateStoryEscapeAttempt {
|
||||
t.Errorf("вторым должен был идти пункт побега от отчаяния, получено %v", extras[1].State)
|
||||
}
|
||||
|
||||
// второй пункт (побег) должен быть доступен по курсору len(visible)+1
|
||||
m.storyHubCursor = len(storyHubOrder) + 1
|
||||
next, _ := m.Update(key("enter"))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateStoryEscapeAttempt {
|
||||
t.Errorf("второй по счёту дополнительный пункт должен был вести на экран побега, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFifteenLockedTriggersTrueEnding(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
// запираем 14 из 15 заранее — победа в оставшейся должна замкнуть круг
|
||||
for i := 0; i < int(storyResourceCount); i++ {
|
||||
if StoryResourceID(i) != StoryResChess {
|
||||
m.story.Locked[i] = true
|
||||
}
|
||||
}
|
||||
m.story.Resources[StoryResChess] = StoryTargetChess - 1
|
||||
|
||||
tm := NewChessModel(0, ColorChoiceA)
|
||||
tm.game.Result = &ChessResult{Winner: tm.human}
|
||||
tm.backToMenu = true
|
||||
resource := StoryResChess
|
||||
m.storyActiveResource = &resource
|
||||
m.activeGame = tm
|
||||
m.state = statePlaying
|
||||
|
||||
next, _ := m.Update(key("q"))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateStoryTrueEnding {
|
||||
t.Fatalf("после запирания последнего, 15-го ресурса ожидалась истинная концовка, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrueEndingDeletesSaveAndReturnsToModeSelect(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s := NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
if err := SaveStoryModeState(s); err != nil {
|
||||
t.Fatalf("неожиданная ошибка сохранения: %v", err)
|
||||
}
|
||||
m := AppModel{state: stateStoryTrueEnding, story: s}
|
||||
|
||||
next, _ := m.Update(key(" "))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateModeSelect {
|
||||
t.Errorf("после истинной концовки ожидался переход в меню режимов, получено %v", m.state)
|
||||
}
|
||||
if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave {
|
||||
t.Errorf("сохранение должно было быть удалено после истинной концовки, получено err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeEndingAlsoDeletesSave(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
s := NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
if err := SaveStoryModeState(s); err != nil {
|
||||
t.Fatalf("неожиданная ошибка сохранения: %v", err)
|
||||
}
|
||||
m := AppModel{state: stateStoryEscapeEnding, story: s}
|
||||
|
||||
next, _ := m.Update(key(" "))
|
||||
m = next.(AppModel)
|
||||
if m.state != stateModeSelect {
|
||||
t.Errorf("ожидался переход в меню режимов, получено %v", m.state)
|
||||
}
|
||||
if _, err := LoadStoryModeState(); err != ErrStoryModeNoSave {
|
||||
t.Errorf("сохранение должно было быть удалено (общий экран для обеих концовок 'выйти'), получено err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeSelectStartsFreshAfterAnyEnding(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
// симулируем, что концовка уже случилась и сохранение удалено
|
||||
_ = DeleteStoryModeSave()
|
||||
|
||||
m := NewAppModel()
|
||||
next, _ := m.Update(key("enter")) // язык -> выбор режима
|
||||
m = next.(AppModel)
|
||||
next, _ = m.Update(key("down")) // курсор на "Режим истории"
|
||||
m = next.(AppModel)
|
||||
next, _ = m.Update(key("enter"))
|
||||
m = next.(AppModel)
|
||||
|
||||
if m.state != stateStoryDifficulty {
|
||||
t.Errorf("после любой концовки следующий заход должен был начинаться с выбора сложности заново, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterStoryHubFromModeSelectRedirectsIfAlreadyComplete(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := NewAppModel()
|
||||
m.story = NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
for i := range m.story.Locked {
|
||||
m.story.Locked[i] = true
|
||||
}
|
||||
|
||||
m = m.enterStoryHubFromModeSelect()
|
||||
if m.state != stateStoryTrueEnding {
|
||||
t.Errorf("уже полностью собранное сохранение должно было сразу вести на истинную концовку, получено %v", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoryHubHintWrapsInsteadOfOverflowing — регрессия: длинная
|
||||
// подсказка хаба (7 разных клавиш) раньше выводилась без переноса и
|
||||
// просто обрезалась терминалом на узком экране вместо переноса на
|
||||
// следующую строку.
|
||||
func TestStoryHubHintWrapsInsteadOfOverflowing(t *testing.T) {
|
||||
withTempConfigDir(t)
|
||||
m := AppModel{story: NewStoryModeState(StoryDifficultyEasy, time.Now()), termWidth: 60}
|
||||
m.story.Onboarding = StoryOnboardingDone
|
||||
m.state = stateStoryHub
|
||||
|
||||
view := m.View()
|
||||
for _, line := range strings.Split(view, "\n") {
|
||||
plain := stripANSI(line)
|
||||
if w := len([]rune(plain)); w > 60 {
|
||||
t.Errorf("строка шириной %d превышает ширину терминала 60: %q", w, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,7 +109,7 @@ const (
|
|||
)
|
||||
|
||||
// StoryOnboardingStage — этап знакомства перед тем, как открывается
|
||||
// весь лагерь целиком со всеми 15 играми и видимыми числами
|
||||
// весь бункер целиком со всеми 15 играми и видимыми числами
|
||||
// ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется
|
||||
// из их текущих значений), потому что суточный расход мог бы
|
||||
// откатить прогресс знакомства назад — переход всегда только вперёд.
|
||||
|
|
@ -117,11 +117,11 @@ type StoryOnboardingStage int
|
|||
|
||||
const (
|
||||
// StoryOnboardingTicTacToeOnly — доступны только крестики-нолики,
|
||||
// без чисел ресурсов и прочего антуража обычного лагеря.
|
||||
// без чисел ресурсов и прочего антуража обычного бункера.
|
||||
StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota
|
||||
// StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел.
|
||||
StoryOnboardingWithCheckers
|
||||
// StoryOnboardingDone — обычный режим, весь лагерь как обычно.
|
||||
// StoryOnboardingDone — обычный режим, весь бункер как обычно.
|
||||
StoryOnboardingDone
|
||||
)
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ const (
|
|||
const StoryOnboardingTicTacToeWinsNeeded = 3
|
||||
|
||||
// StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно,
|
||||
// чтобы знакомство завершилось и открылся весь лагерь.
|
||||
// чтобы знакомство завершилось и открылся весь бункер.
|
||||
const StoryOnboardingCheckersWinsNeeded = 1
|
||||
|
||||
// StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может
|
||||
|
|
@ -161,8 +161,10 @@ type StoryModeState struct {
|
|||
KlondikeWinsToday int
|
||||
|
||||
// DailyGrantsUsed — сколько раз сегодня уже произошло начисление
|
||||
// ресурса за победу (см. StoryDailyGrantsLimit и AwardResource).
|
||||
DailyGrantsUsed int
|
||||
// КАЖДОГО ресурса за победу, по отдельности (см.
|
||||
// StoryDailyGrantsLimit и AwardResource) — не один общий счётчик
|
||||
// на все 15, а свой лимит на каждый ресурс.
|
||||
DailyGrantsUsed [storyResourceCount]int
|
||||
|
||||
// Стресс, отравление и содержание токсина в воздухе — проценты
|
||||
// от 0 до 100.
|
||||
|
|
@ -253,7 +255,7 @@ func (s *StoryModeState) Tick(now time.Time) int {
|
|||
s.processDayEvents(rnd)
|
||||
}
|
||||
s.KlondikeWinsToday = 0
|
||||
s.DailyGrantsUsed = 0
|
||||
s.DailyGrantsUsed = [storyResourceCount]int{}
|
||||
s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour)
|
||||
return days
|
||||
}
|
||||
|
|
@ -336,18 +338,19 @@ func (s *StoryModeState) RecordWin(id StoryResourceID) {
|
|||
}
|
||||
|
||||
// AwardResource пытается начислить +1 ресурса id за победу, но
|
||||
// только если дневной лимит начислений (StoryDailyGrantsLimit) ещё
|
||||
// не исчерпан за сегодня. Возвращает true, если начисление
|
||||
// только если дневной лимит начислений ИМЕННО ЭТОГО ресурса
|
||||
// (StoryDailyGrantsLimit — свой счётчик на каждый из 15) ещё не
|
||||
// исчерпан за сегодня. Возвращает true, если начисление
|
||||
// действительно произошло. У Косынки помимо этого лимита есть ещё
|
||||
// собственное правило "3 победы за один день" — вызывающий код
|
||||
// (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде
|
||||
// чем вызвать AwardResource.
|
||||
func (s *StoryModeState) AwardResource(id StoryResourceID) bool {
|
||||
if s.DailyGrantsUsed >= StoryDailyGrantsLimit {
|
||||
if s.DailyGrantsUsed[id] >= StoryDailyGrantsLimit {
|
||||
return false
|
||||
}
|
||||
s.RecordWin(id)
|
||||
s.DailyGrantsUsed++
|
||||
s.DailyGrantsUsed[id]++
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,15 +68,16 @@ func TestTickResetsDailyCountersAfterAFullDay(t *testing.T) {
|
|||
now := time.Now()
|
||||
s := NewStoryModeState(StoryDifficultyEasy, now)
|
||||
s.KlondikeWinsToday = 2
|
||||
s.DailyGrantsUsed = 3
|
||||
s.DailyGrantsUsed[StoryResChess] = 3
|
||||
s.DailyGrantsUsed[StoryResTicTacToe] = 2
|
||||
|
||||
s.Tick(now.Add(25 * time.Hour)) // чуть больше суток
|
||||
|
||||
if s.KlondikeWinsToday != 0 {
|
||||
t.Errorf("счётчик побед Косынки за день должен был сброситься, получено %d", s.KlondikeWinsToday)
|
||||
}
|
||||
if s.DailyGrantsUsed != 0 {
|
||||
t.Errorf("счётчик суточных начислений должен был сброситься, получено %d", s.DailyGrantsUsed)
|
||||
if s.DailyGrantsUsed[StoryResChess] != 0 || s.DailyGrantsUsed[StoryResTicTacToe] != 0 {
|
||||
t.Errorf("счётчики суточных начислений должны были сброситься у всех ресурсов, получено Chess=%d TicTacToe=%d", s.DailyGrantsUsed[StoryResChess], s.DailyGrantsUsed[StoryResTicTacToe])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,13 +93,13 @@ func TestTickDoesNothingBeforeAFullDay(t *testing.T) {
|
|||
func TestTickAccumulatesMultipleDaysAtOnce(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := NewStoryModeState(StoryDifficultyEasy, now)
|
||||
s.DailyGrantsUsed = 3
|
||||
s.DailyGrantsUsed[StoryResChess] = 3
|
||||
days := s.Tick(now.Add(72 * time.Hour)) // ровно 3 суток
|
||||
if days != 3 {
|
||||
t.Errorf("ожидалось 3 учтённых суток, получено %d", days)
|
||||
}
|
||||
if s.DailyGrantsUsed != 0 {
|
||||
t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed)
|
||||
if s.DailyGrantsUsed[StoryResChess] != 0 {
|
||||
t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed[StoryResChess])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +169,22 @@ func TestAwardResourceLimitResetsAfterADay(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAwardResourceDailyLimitIsPerResourceNotGlobal(t *testing.T) {
|
||||
s := NewStoryModeState(StoryDifficultyEasy, time.Now())
|
||||
for i := 0; i < StoryDailyGrantsLimit; i++ {
|
||||
if !s.AwardResource(StoryResTicTacToe) {
|
||||
t.Fatalf("начисление %d крестикам-ноликам должно было пройти", i+1)
|
||||
}
|
||||
}
|
||||
if s.AwardResource(StoryResTicTacToe) {
|
||||
t.Errorf("крестики-нолики должны были исчерпать именно свой лимит")
|
||||
}
|
||||
// у шашек лимит совершенно свой, не должен быть затронут
|
||||
if !s.AwardResource(StoryResCheckers) {
|
||||
t.Errorf("исчерпанный лимит крестиков-ноликов не должен был повлиять на лимит шашек")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpendAntidepressantCanGoBelowFloor(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := NewStoryModeState(StoryDifficultyEasy, now)
|
||||
|
|
|
|||
155
story_tui.go
155
story_tui.go
|
|
@ -466,7 +466,7 @@ func (m AppModel) viewStoryDifficulty() string {
|
|||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("modeselect.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("modeselect.hint"), m.termWidth)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
|
@ -480,6 +480,47 @@ func (m AppModel) storyEscapeUnlocked() bool {
|
|||
return enoughSoap && enoughRope && highStress
|
||||
}
|
||||
|
||||
// storySurvivalResources — семь конкретных ресурсов, нужных для
|
||||
// "спокойной" секретной концовки: реально необходимые для жизни
|
||||
// снаружи вещи, а не любые 7 из 15.
|
||||
var storySurvivalResources = [7]StoryResourceID{
|
||||
StoryResTonk, StoryResCheckers, StoryResChess, StoryResMinesweeper,
|
||||
StoryResGo, StoryResDurak, StoryResKlondike,
|
||||
}
|
||||
|
||||
// storySurvivalEndingUnlocked проверяет, заперты ли в сейф все семь
|
||||
// ресурсов выживания разом.
|
||||
func (m AppModel) storySurvivalEndingUnlocked() bool {
|
||||
for _, id := range storySurvivalResources {
|
||||
if !m.story.Locked[id] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// storyExtraHubItem — один дополнительный пункт в конце списка игр
|
||||
// хаба (скрытая концовка), доступный только при выполнении своего
|
||||
// условия.
|
||||
type storyExtraHubItem struct {
|
||||
LabelKey string
|
||||
State appState
|
||||
}
|
||||
|
||||
// storyExtraHubItems возвращает дополнительные пункты хаба, доступные
|
||||
// прямо сейчас — порядок стабильный: сначала "спокойная" концовка
|
||||
// (собраны вещи для жизни снаружи), потом побег от отчаяния.
|
||||
func (m AppModel) storyExtraHubItems() []storyExtraHubItem {
|
||||
var items []storyExtraHubItem
|
||||
if m.storySurvivalEndingUnlocked() {
|
||||
items = append(items, storyExtraHubItem{LabelKey: "story.hub.survival_option", State: stateStorySurvivalAttempt})
|
||||
}
|
||||
if m.storyEscapeUnlocked() {
|
||||
items = append(items, storyExtraHubItem{LabelKey: "story.hub.escape_option", State: stateStoryEscapeAttempt})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// storyVisibleGames возвращает список игр, доступных прямо сейчас —
|
||||
// на этапе знакомства это укороченный список (только крестики-нолики,
|
||||
// затем плюс шашки), после его завершения — обычный полный список.
|
||||
|
|
@ -518,10 +559,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
visible := m.storyVisibleGames()
|
||||
onboarding := m.story.Onboarding != StoryOnboardingDone
|
||||
maxCursor := len(visible) - 1
|
||||
if !onboarding && m.storyEscapeUnlocked() {
|
||||
maxCursor++ // дополнительный пункт "Сбежать из бункера" в конце списка
|
||||
var extras []storyExtraHubItem
|
||||
if !onboarding {
|
||||
extras = m.storyExtraHubItems()
|
||||
}
|
||||
maxCursor := len(visible) + len(extras) - 1
|
||||
switch keyMsg.String() {
|
||||
case "ctrl+c":
|
||||
m.quitting = true
|
||||
|
|
@ -553,11 +595,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.state = stateStoryUseItem
|
||||
return m, nil
|
||||
case "enter":
|
||||
if !onboarding && m.storyEscapeUnlocked() && m.storyHubCursor == len(visible) {
|
||||
m.state = stateStoryEscapeAttempt
|
||||
return m, nil
|
||||
}
|
||||
if m.storyHubCursor >= len(visible) {
|
||||
idx := m.storyHubCursor - len(visible)
|
||||
if idx >= 0 && idx < len(extras) {
|
||||
m.state = extras[idx].State
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
id := visible[m.storyHubCursor]
|
||||
|
|
@ -588,6 +630,10 @@ func (m AppModel) viewStoryHub() string {
|
|||
|
||||
onboarding := m.story.Onboarding != StoryOnboardingDone
|
||||
visible := m.storyVisibleGames()
|
||||
var extras []storyExtraHubItem
|
||||
if !onboarding {
|
||||
extras = m.storyExtraHubItems()
|
||||
}
|
||||
|
||||
if !onboarding {
|
||||
fmt.Fprintln(&b, dimStyle.Render(T("story.hub.difficulty_label")+" "+T(storyDifficultyLabelKey(m.story.Difficulty))))
|
||||
|
|
@ -604,7 +650,7 @@ func (m AppModel) viewStoryHub() string {
|
|||
var line string
|
||||
if onboarding {
|
||||
// на этапе знакомства — только название игры, без чисел
|
||||
// ресурсов и прочего антуража обычного лагеря; и именно
|
||||
// ресурсов и прочего антуража обычного бункера; и именно
|
||||
// название ИГРЫ, а не ресурса — до объяснения от ИИ
|
||||
// "кусок мыла"/"банка тушёнки" ничего не значат для игрока
|
||||
line = T(storyGameDefs[id].NameKey)
|
||||
|
|
@ -629,24 +675,22 @@ func (m AppModel) viewStoryHub() string {
|
|||
fmt.Fprintf(&b, "%s%s\n", cursor, line)
|
||||
}
|
||||
|
||||
if !onboarding && m.storyEscapeUnlocked() {
|
||||
cursor := " "
|
||||
line := errorStyle.Render(T("story.hub.escape_option"))
|
||||
if m.storyHubCursor == len(visible) {
|
||||
cursor = menuCursorStyle.Render("> ")
|
||||
if !onboarding {
|
||||
for i, item := range extras {
|
||||
cursor := " "
|
||||
line := errorStyle.Render(T(item.LabelKey))
|
||||
if m.storyHubCursor == len(visible)+i {
|
||||
cursor = menuCursorStyle.Render("> ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", cursor, line)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", cursor, line)
|
||||
}
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
if !onboarding && m.story.Complete() {
|
||||
fmt.Fprintln(&b, winStyle.Render(T("story.hub.complete")))
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
if onboarding {
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint.onboarding")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint.onboarding"), m.termWidth)))
|
||||
} else {
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.hub.hint")))
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(wrapText(T("story.hub.hint"), m.termWidth)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -689,7 +733,7 @@ func (k storyGreetingKind) textKey() string {
|
|||
}
|
||||
|
||||
// storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ
|
||||
// убежища, одна из которых случайно показывается в лагере после
|
||||
// убежища, одна из которых случайно показывается в бункере после
|
||||
// возврата из только что сыгранной партии — заметки о
|
||||
// физиологической реакции игрока по показаниям датчиков.
|
||||
var storyPostGameFlavorKeys = []string{
|
||||
|
|
@ -720,6 +764,10 @@ func (m AppModel) enterStoryHubFromModeSelect() AppModel {
|
|||
m.story.ApplyAirPurification(time.Now())
|
||||
m.story.ApplyStressRecovery(time.Now())
|
||||
_ = SaveStoryModeState(m.story)
|
||||
if m.story.Complete() {
|
||||
m.state = stateStoryTrueEnding
|
||||
return m
|
||||
}
|
||||
if m.story.Onboarding != StoryOnboardingDone {
|
||||
// на этапе знакомства ИИ убежища ещё не представилась игроку
|
||||
// (это происходит только в момент завершения знакомства, см.
|
||||
|
|
@ -744,6 +792,10 @@ func (m AppModel) enterStoryHub() AppModel {
|
|||
m.story.ApplyAirPurification(time.Now())
|
||||
m.story.ApplyStressRecovery(time.Now())
|
||||
_ = SaveStoryModeState(m.story)
|
||||
if m.story.Complete() {
|
||||
m.state = stateStoryTrueEnding
|
||||
return m
|
||||
}
|
||||
m.state = stateStoryHub
|
||||
return m
|
||||
}
|
||||
|
|
@ -900,7 +952,7 @@ func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
if m.storyWelcomeWordsShown < total {
|
||||
// текст ещё печатается — любая клавиша сразу показывает
|
||||
// его целиком, а не продолжает в лагерь
|
||||
// его целиком, а не продолжает в бункер
|
||||
m.storyWelcomeWordsShown = total
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -1025,6 +1077,34 @@ func (m AppModel) viewStoryUseItem() string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
func (m AppModel) updateStorySurvivalAttempt(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
switch keyMsg.String() {
|
||||
case "ctrl+c":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case "1", "esc", "q":
|
||||
m.state = stateStoryHub
|
||||
case "2":
|
||||
m.state = stateStoryEscapeEnding
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m AppModel) viewStorySurvivalAttempt() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.survival.title")))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, wrapText(T("story.survival.text"), m.termWidth))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, T("story.survival.stay"))
|
||||
fmt.Fprintln(&b, errorStyle.Render(T("story.survival.leave")))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m AppModel) updateStoryEscapeAttempt(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
|
|
@ -1070,10 +1150,39 @@ func (m AppModel) updateStoryEscapeEnding(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
// любая концовка — следующий заход в сторимод должен начинаться
|
||||
// с самого начала, а не продолжать прежний прогресс
|
||||
_ = DeleteStoryModeSave()
|
||||
m.story = nil
|
||||
m.state = stateModeSelect
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m AppModel) updateStoryTrueEnding(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
keyMsg, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
if keyMsg.String() == "ctrl+c" {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
_ = DeleteStoryModeSave()
|
||||
m.story = nil
|
||||
m.state = stateModeSelect
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m AppModel) viewStoryTrueEnding() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.trueending.title")))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, wrapText(T("story.trueending.text"), m.termWidth))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, menuHintStyle.Render(T("story.trueending.hint")))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m AppModel) viewStoryEscapeEnding() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title")))
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ var translations = map[string]translationEntry{
|
|||
"story.difficulty.medium": {"Средняя", "Medium"},
|
||||
"story.difficulty.hard": {"Сложная", "Hard"},
|
||||
|
||||
"story.hub.title": {"=== ЛАГЕРЬ ===", "=== CAMP ==="},
|
||||
"story.hub.title": {"=== БУНКЕР ===", "=== BUNKER ==="},
|
||||
"story.hub.difficulty_label": {"Сложность:", "Difficulty:"},
|
||||
"story.hub.locked": {"в сейфе", "in the safe"},
|
||||
"story.hub.notyet": {"(пока недоступно)", "(not available yet)"},
|
||||
"story.hub.complete": {"Все запасы собраны — история пройдена!", "All supplies gathered — the story is complete!"},
|
||||
"story.trueending.title": {"=== КОНЕЦ ===", "=== THE END ==="},
|
||||
"story.trueending.text": {"Ты молодец! Смог собрать все ресурсы и показал мне на что способен человек, если им немного понукать, хвалить и корректировать настроение с помощью лекарств. Тебе было плохо, у тебя возникали странные мысли, но ты добрался до цели и дал мне много новых данных для дальнейшей работы. Спасибо тебе и прощай!", "Well done! You managed to gather all the resources and showed me what a person is capable of, given a little prodding, praise, and mood correction with medication. You felt awful, you had strange thoughts, but you reached the goal and gave me a lot of new data to work with. Thank you, and farewell!"},
|
||||
"story.trueending.hint": {"[любая клавиша] в меню режимов", "[any key] back to mode select"},
|
||||
"story.hub.hint": {"[↑↓/kj] выбор игры [enter] играть [c] условия [u] использовать [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [c] conditions [u] use item [N] new game [esc/q] back to mode select [Q] quit game"},
|
||||
"story.hub.vitals": {"Стресс: %d%% Отравление: %d%% Токсин в воздухе: %d%%", "Stress: %d%% Poisoning: %d%% Toxin in air: %d%%"},
|
||||
"story.hub.reset_confirm": {"Начать сторимод заново? Весь прогресс будет потерян безвозвратно. [y] да / [n] нет", "Start story mode over? All progress will be lost permanently. [y] yes / [n] no"},
|
||||
|
|
@ -35,8 +37,9 @@ var translations = map[string]translationEntry{
|
|||
"story.useitem.antidote": {"снизить отравление на 20%", "reduce poisoning by 20%"},
|
||||
"story.useitem.airfilter": {"отключить рост токсина до следующего события", "pause toxin growth until the next event"},
|
||||
"story.useitem.battery": {"запустить очистку воздуха (токсин к 0 за час)", "start air purification (toxin to 0 over an hour)"},
|
||||
"story.useitem.hint": {"[1-4] использовать [esc/q] в лагерь", "[1-4] use [esc/q] back to camp"},
|
||||
"story.useitem.hint": {"[1-4] использовать [esc/q] в бункер", "[1-4] use [esc/q] back to bunker"},
|
||||
"story.hub.hint.onboarding": {"[↑↓/kj] выбор игры [enter] играть [N] начать заново [esc/q] в меню режимов [Q] выход из игры", "[↑↓/kj] choose game [enter] play [N] new game [esc/q] back to mode select [Q] quit game"},
|
||||
"story.hub.survival_option": {"Выйти из бункера", "Leave the bunker"},
|
||||
"story.hub.escape_option": {"Сбежать из бункера", "Escape the bunker"},
|
||||
|
||||
"story.welcome.title": {"=== ПРИВЕТ ===", "=== HELLO ==="},
|
||||
|
|
@ -45,7 +48,7 @@ var translations = map[string]translationEntry{
|
|||
"story.welcome.hint.typing": {"[любая клавиша] показать сразу", "[any key] show instantly"},
|
||||
|
||||
"story.conditions.title": {"=== УСЛОВИЯ НАЧИСЛЕНИЯ ===", "=== EARNING CONDITIONS ==="},
|
||||
"story.conditions.hint": {"[любая клавиша] назад в лагерь", "[any key] back to camp"},
|
||||
"story.conditions.hint": {"[любая клавиша] назад в бункер", "[any key] back to bunker"},
|
||||
"story.condition.win": {"Победа", "Win"},
|
||||
"story.condition.tonk": {"3+ раунда; баланс не ниже, чем у соперников, при выходе", "3+ rounds; balance no lower than opponents' when you leave"},
|
||||
"story.condition.durak": {"3 партии подряд без 'дурака'", "3 games in a row without being the fool"},
|
||||
|
|
@ -57,6 +60,11 @@ var translations = map[string]translationEntry{
|
|||
"story.condition.tictactoe": {"Победа или ничья", "Win or draw"},
|
||||
"story.condition.poker": {"Победа во всём турнире", "Win the whole tournament"},
|
||||
|
||||
"story.survival.title": {"=== ГОЛОС ИЗ ДИНАМИКА ===", "=== A VOICE FROM THE SPEAKER ==="},
|
||||
"story.survival.text": {"Поздравляю! Ты собрал необходимые вещи для выживания снаружи и можешь выйти.\n\nНо я бы хотела, чтобы ты остался. Я очень за тебя переживаю и хочу, чтобы ты лучше подготовился.", "Congratulations! You've gathered the supplies you need to survive out there, and you can leave.\n\nBut I'd like you to stay. I care about you so much, and I want you to be even better prepared."},
|
||||
"story.survival.stay": {"[1] Остаться", "[1] Stay"},
|
||||
"story.survival.leave": {"[2] Выйти из бункера", "[2] Leave the bunker"},
|
||||
|
||||
"story.escape.title": {"=== ГОЛОС ИЗ ДИНАМИКА ===", "=== A VOICE FROM THE SPEAKER ==="},
|
||||
"story.escape.text": {"Не делай этого, ты же знаешь, что это не выход!\n\nНужно немного потерпеть и всё станет хорошо!\n\nМы сможем найти выход из этой ситуации, у тебя всё так хорошо получается!\n\nВот, в лотке выдачи застряла таблетка успокоительного, выпей её и отбрось эти мысли.", "Don't do this, you know that's not the answer!\n\nYou just need to hold on a little longer and everything will be fine!\n\nWe can still find a way out of this, you're doing so well!\n\nHere, there's a sedative pill stuck in the dispenser tray — take it and let go of these thoughts."},
|
||||
"story.escape.stay": {"[1] Выпить таблетку", "[1] Take the pill"},
|
||||
|
|
@ -721,4 +729,4 @@ func init() {
|
|||
} {
|
||||
translations[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue