Fixed story mod

This commit is contained in:
Magnus Root 2026-07-16 09:53:25 +03:00
parent 79e91784d2
commit 3f90ba3220
15 changed files with 874 additions and 111 deletions

View file

@ -42,11 +42,15 @@ type CheckersModel struct {
// значение по умолчанию из CheckersBot). // значение по умолчанию из CheckersBot).
func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel { func NewCheckersModel(depth int, colorChoice ColorChoice) CheckersModel {
human := resolveCheckersColor(colorChoice) human := resolveCheckersColor(colorChoice)
startRow := 5
if human == CheckersBlack {
startRow = 2
}
return CheckersModel{ return CheckersModel{
game: NewCheckersGame(), game: NewCheckersGame(),
bot: CheckersBot{Depth: depth}, bot: CheckersBot{Depth: depth},
human: human, 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 return m, nil
} }
flip := m.human == CheckersBlack
switch key { switch key {
case "up", "k": 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-- m.cursor.Row--
} }
case "down", "j": 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++ m.cursor.Row++
} }
case "left", "h": 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-- m.cursor.Col--
} }
case "right", "l": 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++ m.cursor.Col++
} }
case "esc": case "esc":
@ -249,11 +270,30 @@ func (m CheckersModel) legalDestinationSet() map[CheckersPos]bool {
func (m CheckersModel) renderBoard() string { func (m CheckersModel) renderBoard() string {
dests := m.legalDestinationSet() dests := m.legalDestinationSet()
flip := m.human == CheckersBlack
var b strings.Builder 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) 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} pos := CheckersPos{Row: r, Col: c}
cellStyle := checkersLightSquare cellStyle := checkersLightSquare
if (r+c)%2 == 1 { if (r+c)%2 == 1 {

View file

@ -228,3 +228,39 @@ func TestNewCheckersModelRandomColorGivesValidColor(t *testing.T) {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) 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)
}
}

View file

@ -32,11 +32,15 @@ type ChessModel struct {
// глубина поиска бота в полуходах (0 -> значение по умолчанию). // глубина поиска бота в полуходах (0 -> значение по умолчанию).
func NewChessModel(depth int, colorChoice ColorChoice) ChessModel { func NewChessModel(depth int, colorChoice ColorChoice) ChessModel {
human := resolveChessColor(colorChoice) human := resolveChessColor(colorChoice)
startRow := 7
if human == ChessBlack {
startRow = 0
}
return ChessModel{ return ChessModel{
game: NewChessGame(), game: NewChessGame(),
bot: ChessBot{Depth: depth}, bot: ChessBot{Depth: depth},
human: human, 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 return m, nil
} }
flip := m.human == ChessBlack
switch key { switch key {
case "up", "k": 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-- m.cursor.Row--
} }
case "down", "j": 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++ m.cursor.Row++
} }
case "left", "h": 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-- m.cursor.Col--
} }
case "right", "l": 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++ m.cursor.Col++
} }
case "esc": case "esc":
@ -251,11 +272,30 @@ func (m ChessModel) legalDestinationSet() map[ChessPos]bool {
func (m ChessModel) renderBoard() string { func (m ChessModel) renderBoard() string {
dests := m.legalDestinationSet() dests := m.legalDestinationSet()
flip := m.human == ChessBlack
var b strings.Builder 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) 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} pos := ChessPos{Row: r, Col: c}
cellStyle := checkersLightSquare cellStyle := checkersLightSquare
if (r+c)%2 == 1 { if (r+c)%2 == 1 {

View file

@ -252,3 +252,50 @@ func TestNewChessModelRandomColorGivesValidColor(t *testing.T) {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) 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)

Binary file not shown.

BIN
ggc.exe (Stored with Git LFS)

Binary file not shown.

167
menu.go
View file

@ -234,8 +234,10 @@ const (
stateStoryHub stateStoryHub
stateStoryConditions stateStoryConditions
stateStoryUseItem stateStoryUseItem
stateStorySurvivalAttempt
stateStoryEscapeAttempt stateStoryEscapeAttempt
stateStoryEscapeEnding stateStoryEscapeEnding
stateStoryTrueEnding
stateMenu stateMenu
stateRulesMenu stateRulesMenu
stateRules stateRules
@ -764,6 +766,8 @@ type AppModel struct {
storyGreeting storyGreetingKind storyGreeting storyGreetingKind
storyPostGameFlavor string storyPostGameFlavor string
storyConfirmingReset bool storyConfirmingReset bool
cheatArmed bool
cheatBuffer string
storyHubCursor int storyHubCursor int
storyActiveResource *StoryResourceID storyActiveResource *StoryResourceID
cursor int cursor int
@ -820,6 +824,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
} }
if handled, next, cmd := m.handleStoryCheatCode(msg); handled {
return next, cmd
}
if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
m.termWidth = sizeMsg.Width m.termWidth = sizeMsg.Width
m.termHeight = sizeMsg.Height m.termHeight = sizeMsg.Height
@ -862,10 +869,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateStoryConditions(msg) return m.updateStoryConditions(msg)
case stateStoryUseItem: case stateStoryUseItem:
return m.updateStoryUseItem(msg) return m.updateStoryUseItem(msg)
case stateStorySurvivalAttempt:
return m.updateStorySurvivalAttempt(msg)
case stateStoryEscapeAttempt: case stateStoryEscapeAttempt:
return m.updateStoryEscapeAttempt(msg) return m.updateStoryEscapeAttempt(msg)
case stateStoryEscapeEnding: case stateStoryEscapeEnding:
return m.updateStoryEscapeEnding(msg) return m.updateStoryEscapeEnding(msg)
case stateStoryTrueEnding:
return m.updateStoryTrueEnding(msg)
case stateRulesMenu: case stateRulesMenu:
return m.updateRulesMenu(msg) return m.updateRulesMenu(msg)
case stateRules: case stateRules:
@ -1611,36 +1622,12 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
if backToMenu { if backToMenu {
if m.storyActiveResource != nil { if m.storyActiveResource != nil {
id := *m.storyActiveResource id := *m.storyActiveResource
won := false
if def, ok := storyGameDefs[id]; ok { if def, ok := storyGameDefs[id]; ok {
if finished, won := def.Outcome(m.activeGame); finished && won { finished, w := def.Outcome(m.activeGame)
if id == StoryResKlondike { won = finished && w
// у Косынки особое правило — 3 победы за
// один день, а не начисление на каждую;
// суточный лимит начислений (AwardResource)
// проверяется уже поверх этого правила
m.story.KlondikeWinsToday++
if m.story.KlondikeWinsToday == 3 {
m.story.AwardResource(id)
}
} else {
m.story.AwardResource(id)
}
}
} }
m.storyActiveResource = nil return m.storyProcessGameOutcome(id, won)
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
} }
m.state = stateMenu m.state = stateMenu
m.activeGame = nil m.activeGame = nil
@ -1649,6 +1636,94 @@ func (m AppModel) updatePlaying(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, 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 { func (m AppModel) View() string {
if m.quitting { if m.quitting {
return T("common.goodbye") return T("common.goodbye")
@ -1676,10 +1751,14 @@ func (m AppModel) View() string {
return m.viewStoryConditions() return m.viewStoryConditions()
case stateStoryUseItem: case stateStoryUseItem:
return m.viewStoryUseItem() return m.viewStoryUseItem()
case stateStorySurvivalAttempt:
return m.viewStorySurvivalAttempt()
case stateStoryEscapeAttempt: case stateStoryEscapeAttempt:
return m.viewStoryEscapeAttempt() return m.viewStoryEscapeAttempt()
case stateStoryEscapeEnding: case stateStoryEscapeEnding:
return m.viewStoryEscapeEnding() return m.viewStoryEscapeEnding()
case stateStoryTrueEnding:
return m.viewStoryTrueEnding()
case stateRulesMenu: case stateRulesMenu:
return m.viewRulesMenu() return m.viewRulesMenu()
case stateRules: case stateRules:
@ -1727,7 +1806,7 @@ func (m AppModel) viewLanguage() string {
fmt.Fprintf(&b, "%s%s\n", cursor, label) fmt.Fprintf(&b, "%s%s\n", cursor, label)
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1747,7 +1826,7 @@ func (m AppModel) viewModeSelect() string {
fmt.Fprintf(&b, "%s%s\n", cursor, label) fmt.Fprintf(&b, "%s%s\n", cursor, label)
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1791,7 +1870,7 @@ func (m AppModel) viewMenu() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1825,7 +1904,7 @@ func (m AppModel) viewRulesMenu() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1851,7 +1930,7 @@ func (m AppModel) viewRules() string {
if maxScroll > 0 { if maxScroll > 0 {
scrollInfo = " " + Tf("rules.scroll_info", start+1, end, len(lines)) 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() 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()))) T("stake.pot_info"), pot, T("stake.tonk_win"), m.settings.multiplier(), pot*m.settings.multiplier())))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1899,7 +1978,7 @@ func (m AppModel) viewPlayerCount() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1))) fmt.Fprintf(&b, "%s\n", menuDescStyle.Render(Tf("playercount.bots", m.playerCount.count-1)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1936,7 +2015,7 @@ func (m AppModel) viewBlackjackSettings() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1964,7 +2043,7 @@ func (m AppModel) viewCheckersSettings() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -1985,7 +2064,7 @@ func (m AppModel) viewTicTacToeSettings() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2017,7 +2096,7 @@ func (m AppModel) viewNardySettings() string {
desc := nardyDifficultyLevels[m.nardy.difficultyIdx].DescKey desc := nardyDifficultyLevels[m.nardy.difficultyIdx].DescKey
fmt.Fprintln(&b, dimStyle.Render(T(desc))) fmt.Fprintln(&b, dimStyle.Render(T(desc)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2045,7 +2124,7 @@ func (m AppModel) viewUrSettings() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2073,7 +2152,7 @@ func (m AppModel) viewSenetSettings() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2101,7 +2180,7 @@ func (m AppModel) viewChessSettings() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey))) fmt.Fprintln(&b, dimStyle.Render(T(lvl.DescKey)))
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2130,7 +2209,7 @@ func (m AppModel) viewGoSettings() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -2151,6 +2230,6 @@ func (m AppModel) viewMinesweeperSettings() string {
} }
fmt.Fprintln(&b) 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() return b.String()
} }

View file

@ -1714,4 +1714,4 @@ func TestLowercaseQDoesNotQuitAppFromModeSelect(t *testing.T) {
if m.state != stateLanguage { if m.state != stateLanguage {
t.Errorf("строчная q должна была вернуть на экран выбора языка, получено %v", m.state) t.Errorf("строчная q должна была вернуть на экран выбора языка, получено %v", m.state)
} }
} }

View file

@ -284,7 +284,11 @@ func (m NardyModel) View() string {
nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack])) nardyPlayerSymbol(NardyBlack), m.game.BorneOff[NardyBlack]))
fmt.Fprintln(&b) 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)) fmt.Fprintln(&b, dimStyle.Render(you))
switch m.game.Phase { switch m.game.Phase {

View file

@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
) )
@ -287,3 +288,17 @@ func TestNewNardyModelRandomColorGivesValidColor(t *testing.T) {
t.Errorf("случайный выбор цвета должен был давать оба варианта за 50 попыток: белые=%v чёрные=%v", seenWhite, seenBlack) 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)
}
}

View file

@ -1222,4 +1222,369 @@ func TestDoorKnockAlertDismissGoesToHubWhenNothingElsePending(t *testing.T) {
if m.state != stateStoryHub { if m.state != stateStoryHub {
t.Errorf("ожидался переход в хаб, получено %v", m.state) 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)
}
}
} }

View file

@ -109,7 +109,7 @@ const (
) )
// StoryOnboardingStage — этап знакомства перед тем, как открывается // StoryOnboardingStage — этап знакомства перед тем, как открывается
// весь лагерь целиком со всеми 15 играми и видимыми числами // весь бункер целиком со всеми 15 играми и видимыми числами
// ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется // ресурсов. Хранится отдельно от самих ресурсов (а не вычисляется
// из их текущих значений), потому что суточный расход мог бы // из их текущих значений), потому что суточный расход мог бы
// откатить прогресс знакомства назад — переход всегда только вперёд. // откатить прогресс знакомства назад — переход всегда только вперёд.
@ -117,11 +117,11 @@ type StoryOnboardingStage int
const ( const (
// StoryOnboardingTicTacToeOnly — доступны только крестики-нолики, // StoryOnboardingTicTacToeOnly — доступны только крестики-нолики,
// без чисел ресурсов и прочего антуража обычного лагеря. // без чисел ресурсов и прочего антуража обычного бункера.
StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota StoryOnboardingTicTacToeOnly StoryOnboardingStage = iota
// StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел. // StoryOnboardingWithCheckers — плюс шашки, всё ещё без чисел.
StoryOnboardingWithCheckers StoryOnboardingWithCheckers
// StoryOnboardingDone — обычный режим, весь лагерь как обычно. // StoryOnboardingDone — обычный режим, весь бункер как обычно.
StoryOnboardingDone StoryOnboardingDone
) )
@ -130,7 +130,7 @@ const (
const StoryOnboardingTicTacToeWinsNeeded = 3 const StoryOnboardingTicTacToeWinsNeeded = 3
// StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно, // StoryOnboardingCheckersWinsNeeded — сколько побед в шашках нужно,
// чтобы знакомство завершилось и открылся весь лагерь. // чтобы знакомство завершилось и открылся весь бункер.
const StoryOnboardingCheckersWinsNeeded = 1 const StoryOnboardingCheckersWinsNeeded = 1
// StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может // StoryDailyGrantsLimit — сколько раз в реальные сутки вообще может
@ -161,8 +161,10 @@ type StoryModeState struct {
KlondikeWinsToday int KlondikeWinsToday int
// DailyGrantsUsed — сколько раз сегодня уже произошло начисление // DailyGrantsUsed — сколько раз сегодня уже произошло начисление
// ресурса за победу (см. StoryDailyGrantsLimit и AwardResource). // КАЖДОГО ресурса за победу, по отдельности (см.
DailyGrantsUsed int // StoryDailyGrantsLimit и AwardResource) — не один общий счётчик
// на все 15, а свой лимит на каждый ресурс.
DailyGrantsUsed [storyResourceCount]int
// Стресс, отравление и содержание токсина в воздухе — проценты // Стресс, отравление и содержание токсина в воздухе — проценты
// от 0 до 100. // от 0 до 100.
@ -253,7 +255,7 @@ func (s *StoryModeState) Tick(now time.Time) int {
s.processDayEvents(rnd) s.processDayEvents(rnd)
} }
s.KlondikeWinsToday = 0 s.KlondikeWinsToday = 0
s.DailyGrantsUsed = 0 s.DailyGrantsUsed = [storyResourceCount]int{}
s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour) s.LastTick = s.LastTick.Add(time.Duration(days) * 24 * time.Hour)
return days return days
} }
@ -336,18 +338,19 @@ func (s *StoryModeState) RecordWin(id StoryResourceID) {
} }
// AwardResource пытается начислить +1 ресурса id за победу, но // AwardResource пытается начислить +1 ресурса id за победу, но
// только если дневной лимит начислений (StoryDailyGrantsLimit) ещё // только если дневной лимит начислений ИМЕННО ЭТОГО ресурса
// не исчерпан за сегодня. Возвращает true, если начисление // (StoryDailyGrantsLimit — свой счётчик на каждый из 15) ещё не
// исчерпан за сегодня. Возвращает true, если начисление
// действительно произошло. У Косынки помимо этого лимита есть ещё // действительно произошло. У Косынки помимо этого лимита есть ещё
// собственное правило "3 победы за один день" — вызывающий код // собственное правило "3 победы за один день" — вызывающий код
// (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде // (menu.go) отслеживает это отдельно через KlondikeWinsToday, прежде
// чем вызвать AwardResource. // чем вызвать AwardResource.
func (s *StoryModeState) AwardResource(id StoryResourceID) bool { func (s *StoryModeState) AwardResource(id StoryResourceID) bool {
if s.DailyGrantsUsed >= StoryDailyGrantsLimit { if s.DailyGrantsUsed[id] >= StoryDailyGrantsLimit {
return false return false
} }
s.RecordWin(id) s.RecordWin(id)
s.DailyGrantsUsed++ s.DailyGrantsUsed[id]++
return true return true
} }

View file

@ -68,15 +68,16 @@ func TestTickResetsDailyCountersAfterAFullDay(t *testing.T) {
now := time.Now() now := time.Now()
s := NewStoryModeState(StoryDifficultyEasy, now) s := NewStoryModeState(StoryDifficultyEasy, now)
s.KlondikeWinsToday = 2 s.KlondikeWinsToday = 2
s.DailyGrantsUsed = 3 s.DailyGrantsUsed[StoryResChess] = 3
s.DailyGrantsUsed[StoryResTicTacToe] = 2
s.Tick(now.Add(25 * time.Hour)) // чуть больше суток s.Tick(now.Add(25 * time.Hour)) // чуть больше суток
if s.KlondikeWinsToday != 0 { if s.KlondikeWinsToday != 0 {
t.Errorf("счётчик побед Косынки за день должен был сброситься, получено %d", s.KlondikeWinsToday) t.Errorf("счётчик побед Косынки за день должен был сброситься, получено %d", s.KlondikeWinsToday)
} }
if s.DailyGrantsUsed != 0 { if s.DailyGrantsUsed[StoryResChess] != 0 || s.DailyGrantsUsed[StoryResTicTacToe] != 0 {
t.Errorf("счётчик суточных начислений должен был сброситься, получено %d", s.DailyGrantsUsed) 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) { func TestTickAccumulatesMultipleDaysAtOnce(t *testing.T) {
now := time.Now() now := time.Now()
s := NewStoryModeState(StoryDifficultyEasy, now) s := NewStoryModeState(StoryDifficultyEasy, now)
s.DailyGrantsUsed = 3 s.DailyGrantsUsed[StoryResChess] = 3
days := s.Tick(now.Add(72 * time.Hour)) // ровно 3 суток days := s.Tick(now.Add(72 * time.Hour)) // ровно 3 суток
if days != 3 { if days != 3 {
t.Errorf("ожидалось 3 учтённых суток, получено %d", days) t.Errorf("ожидалось 3 учтённых суток, получено %d", days)
} }
if s.DailyGrantsUsed != 0 { if s.DailyGrantsUsed[StoryResChess] != 0 {
t.Errorf("суточный счётчик должен был сброситься независимо от числа прошедших суток, получено %d", s.DailyGrantsUsed) 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) { func TestSpendAntidepressantCanGoBelowFloor(t *testing.T) {
now := time.Now() now := time.Now()
s := NewStoryModeState(StoryDifficultyEasy, now) s := NewStoryModeState(StoryDifficultyEasy, now)

View file

@ -466,7 +466,7 @@ func (m AppModel) viewStoryDifficulty() string {
fmt.Fprintf(&b, "%s%s\n", cursor, label) fmt.Fprintf(&b, "%s%s\n", cursor, label)
} }
fmt.Fprintln(&b) 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() return b.String()
} }
@ -480,6 +480,47 @@ func (m AppModel) storyEscapeUnlocked() bool {
return enoughSoap && enoughRope && highStress 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 возвращает список игр, доступных прямо сейчас — // storyVisibleGames возвращает список игр, доступных прямо сейчас —
// на этапе знакомства это укороченный список (только крестики-нолики, // на этапе знакомства это укороченный список (только крестики-нолики,
// затем плюс шашки), после его завершения — обычный полный список. // затем плюс шашки), после его завершения — обычный полный список.
@ -518,10 +559,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
visible := m.storyVisibleGames() visible := m.storyVisibleGames()
onboarding := m.story.Onboarding != StoryOnboardingDone onboarding := m.story.Onboarding != StoryOnboardingDone
maxCursor := len(visible) - 1 var extras []storyExtraHubItem
if !onboarding && m.storyEscapeUnlocked() { if !onboarding {
maxCursor++ // дополнительный пункт "Сбежать из бункера" в конце списка extras = m.storyExtraHubItems()
} }
maxCursor := len(visible) + len(extras) - 1
switch keyMsg.String() { switch keyMsg.String() {
case "ctrl+c": case "ctrl+c":
m.quitting = true m.quitting = true
@ -553,11 +595,11 @@ func (m AppModel) updateStoryHub(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateStoryUseItem m.state = stateStoryUseItem
return m, nil return m, nil
case "enter": case "enter":
if !onboarding && m.storyEscapeUnlocked() && m.storyHubCursor == len(visible) {
m.state = stateStoryEscapeAttempt
return m, nil
}
if m.storyHubCursor >= len(visible) { if m.storyHubCursor >= len(visible) {
idx := m.storyHubCursor - len(visible)
if idx >= 0 && idx < len(extras) {
m.state = extras[idx].State
}
return m, nil return m, nil
} }
id := visible[m.storyHubCursor] id := visible[m.storyHubCursor]
@ -588,6 +630,10 @@ func (m AppModel) viewStoryHub() string {
onboarding := m.story.Onboarding != StoryOnboardingDone onboarding := m.story.Onboarding != StoryOnboardingDone
visible := m.storyVisibleGames() visible := m.storyVisibleGames()
var extras []storyExtraHubItem
if !onboarding {
extras = m.storyExtraHubItems()
}
if !onboarding { if !onboarding {
fmt.Fprintln(&b, dimStyle.Render(T("story.hub.difficulty_label")+" "+T(storyDifficultyLabelKey(m.story.Difficulty)))) 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 var line string
if onboarding { if onboarding {
// на этапе знакомства — только название игры, без чисел // на этапе знакомства — только название игры, без чисел
// ресурсов и прочего антуража обычного лагеря; и именно // ресурсов и прочего антуража обычного бункера; и именно
// название ИГРЫ, а не ресурса — до объяснения от ИИ // название ИГРЫ, а не ресурса — до объяснения от ИИ
// "кусок мыла"/"банка тушёнки" ничего не значат для игрока // "кусок мыла"/"банка тушёнки" ничего не значат для игрока
line = T(storyGameDefs[id].NameKey) line = T(storyGameDefs[id].NameKey)
@ -629,24 +675,22 @@ func (m AppModel) viewStoryHub() string {
fmt.Fprintf(&b, "%s%s\n", cursor, line) fmt.Fprintf(&b, "%s%s\n", cursor, line)
} }
if !onboarding && m.storyEscapeUnlocked() { if !onboarding {
cursor := " " for i, item := range extras {
line := errorStyle.Render(T("story.hub.escape_option")) cursor := " "
if m.storyHubCursor == len(visible) { line := errorStyle.Render(T(item.LabelKey))
cursor = menuCursorStyle.Render("> ") 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) fmt.Fprintln(&b)
if !onboarding && m.story.Complete() {
fmt.Fprintln(&b, winStyle.Render(T("story.hub.complete")))
fmt.Fprintln(&b)
}
if onboarding { 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 { } 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() return b.String()
} }
@ -689,7 +733,7 @@ func (k storyGreetingKind) textKey() string {
} }
// storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ // storyPostGameFlavorKeys — короткие фразы-наблюдения от лица ИИ
// убежища, одна из которых случайно показывается в лагере после // убежища, одна из которых случайно показывается в бункере после
// возврата из только что сыгранной партии — заметки о // возврата из только что сыгранной партии — заметки о
// физиологической реакции игрока по показаниям датчиков. // физиологической реакции игрока по показаниям датчиков.
var storyPostGameFlavorKeys = []string{ var storyPostGameFlavorKeys = []string{
@ -720,6 +764,10 @@ func (m AppModel) enterStoryHubFromModeSelect() AppModel {
m.story.ApplyAirPurification(time.Now()) m.story.ApplyAirPurification(time.Now())
m.story.ApplyStressRecovery(time.Now()) m.story.ApplyStressRecovery(time.Now())
_ = SaveStoryModeState(m.story) _ = SaveStoryModeState(m.story)
if m.story.Complete() {
m.state = stateStoryTrueEnding
return m
}
if m.story.Onboarding != StoryOnboardingDone { if m.story.Onboarding != StoryOnboardingDone {
// на этапе знакомства ИИ убежища ещё не представилась игроку // на этапе знакомства ИИ убежища ещё не представилась игроку
// (это происходит только в момент завершения знакомства, см. // (это происходит только в момент завершения знакомства, см.
@ -744,6 +792,10 @@ func (m AppModel) enterStoryHub() AppModel {
m.story.ApplyAirPurification(time.Now()) m.story.ApplyAirPurification(time.Now())
m.story.ApplyStressRecovery(time.Now()) m.story.ApplyStressRecovery(time.Now())
_ = SaveStoryModeState(m.story) _ = SaveStoryModeState(m.story)
if m.story.Complete() {
m.state = stateStoryTrueEnding
return m
}
m.state = stateStoryHub m.state = stateStoryHub
return m return m
} }
@ -900,7 +952,7 @@ func (m AppModel) updateStoryWelcome(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if m.storyWelcomeWordsShown < total { if m.storyWelcomeWordsShown < total {
// текст ещё печатается — любая клавиша сразу показывает // текст ещё печатается — любая клавиша сразу показывает
// его целиком, а не продолжает в лагерь // его целиком, а не продолжает в бункер
m.storyWelcomeWordsShown = total m.storyWelcomeWordsShown = total
return m, nil return m, nil
} }
@ -1025,6 +1077,34 @@ func (m AppModel) viewStoryUseItem() string {
return b.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) { func (m AppModel) updateStoryEscapeAttempt(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg) keyMsg, ok := msg.(tea.KeyMsg)
if !ok { if !ok {
@ -1070,10 +1150,39 @@ func (m AppModel) updateStoryEscapeEnding(msg tea.Msg) (tea.Model, tea.Cmd) {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
} }
// любая концовка — следующий заход в сторимод должен начинаться
// с самого начала, а не продолжать прежний прогресс
_ = DeleteStoryModeSave()
m.story = nil
m.state = stateModeSelect m.state = stateModeSelect
return m, nil 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 { func (m AppModel) viewStoryEscapeEnding() string {
var b strings.Builder var b strings.Builder
fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title"))) fmt.Fprintln(&b, menuTitleStyle.Render(T("story.escapeending.title")))

View file

@ -20,11 +20,13 @@ var translations = map[string]translationEntry{
"story.difficulty.medium": {"Средняя", "Medium"}, "story.difficulty.medium": {"Средняя", "Medium"},
"story.difficulty.hard": {"Сложная", "Hard"}, "story.difficulty.hard": {"Сложная", "Hard"},
"story.hub.title": {"=== ЛАГЕРЬ ===", "=== CAMP ==="}, "story.hub.title": {"=== БУНКЕР ===", "=== BUNKER ==="},
"story.hub.difficulty_label": {"Сложность:", "Difficulty:"}, "story.hub.difficulty_label": {"Сложность:", "Difficulty:"},
"story.hub.locked": {"в сейфе", "in the safe"}, "story.hub.locked": {"в сейфе", "in the safe"},
"story.hub.notyet": {"(пока недоступно)", "(not available yet)"}, "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.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.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"}, "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.antidote": {"снизить отравление на 20%", "reduce poisoning by 20%"},
"story.useitem.airfilter": {"отключить рост токсина до следующего события", "pause toxin growth until the next event"}, "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.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.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.hub.escape_option": {"Сбежать из бункера", "Escape the bunker"},
"story.welcome.title": {"=== ПРИВЕТ ===", "=== HELLO ==="}, "story.welcome.title": {"=== ПРИВЕТ ===", "=== HELLO ==="},
@ -45,7 +48,7 @@ var translations = map[string]translationEntry{
"story.welcome.hint.typing": {"[любая клавиша] показать сразу", "[any key] show instantly"}, "story.welcome.hint.typing": {"[любая клавиша] показать сразу", "[any key] show instantly"},
"story.conditions.title": {"=== УСЛОВИЯ НАЧИСЛЕНИЯ ===", "=== EARNING CONDITIONS ==="}, "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.win": {"Победа", "Win"},
"story.condition.tonk": {"3+ раунда; баланс не ниже, чем у соперников, при выходе", "3+ rounds; balance no lower than opponents' when you leave"}, "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"}, "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.tictactoe": {"Победа или ничья", "Win or draw"},
"story.condition.poker": {"Победа во всём турнире", "Win the whole tournament"}, "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.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.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"}, "story.escape.stay": {"[1] Выпить таблетку", "[1] Take the pill"},
@ -721,4 +729,4 @@ func init() {
} { } {
translations[k] = v translations[k] = v
} }
} }