diff --git a/cmd/server/main.go b/cmd/server/main.go index 82f5cc5..5ac41e8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,8 +6,10 @@ import ( "errors" "fmt" "log" + "math/rand/v2" "net" "os" + "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" @@ -132,7 +134,7 @@ func main() { // The SSH server need its own keys, this will create a keypair in the // given path if it doesn't exist yet. // By default, it will create an ED25519 key. - wish.WithHostKeyPath(".ssh/id_ed25519"), + wish.WithHostKeyPath("/data/.ssh/ssh_host_ed25519_key"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { angler, err := db.GetAnglerByKey(key) @@ -207,6 +209,15 @@ const ( PageGameplay ) +type playerState int + +const ( + PlayerIdle playerState = iota + PlayerCasting + PlayerCaught + PlayerFailed +) + type tab int const ( @@ -217,21 +228,25 @@ const ( ) type model struct { - profile string - term string - width int - height int - bg string - DB *db.Database - page page - angler models.Angler - tabs []string - tab tab - error string - ti textinput.Model - spinner spinner.Model - active tea.Model - cast models.Cast + ctx context.Context + cancel context.CancelFunc + profile string + term string + width int + height int + bg string + DB *db.Database + page page + angler models.Angler + tabs []string + tab tab + error string + ti textinput.Model + spinner spinner.Model + active tea.Model + cast models.Cast + playerState playerState + message string } func (m model) Init() tea.Cmd { @@ -251,6 +266,8 @@ func (m model) Init() tea.Cmd { ) } +const chooseNamePrompt = "aye fresh meat, got a name laddie?" + func (m model) ChooseNameView() tea.View { var error string @@ -258,7 +275,9 @@ func (m model) ChooseNameView() tea.View { error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error) } - input := lipgloss.JoinVertical(lipgloss.Top, "aye fresh meat, got a name laddie?", m.ti.View(), error) + // m.ti.SetWidth(lipgloss.Width(chooseNamePrompt)) + + input := lipgloss.JoinVertical(lipgloss.Top, chooseNamePrompt, m.ti.View(), error) center := lipgloss.Place( m.width, @@ -284,15 +303,15 @@ func (m model) ChooseNameView() tea.View { bottom, ) - var c *tea.Cursor + // var c *tea.Cursor - c = m.ti.Cursor() + // c = m.ti.Cursor() - c.Y += lipgloss.Height("aye fresh meat, got a name laddie?") + (m.height-3-lipgloss.Height(input))/2 - c.X += (m.width - lipgloss.Width(input)) / 2 + // c.Y += lipgloss.Height(prompt) + (m.height-3-lipgloss.Height(input))/2 + // c.X += (m.width - lipgloss.Width(input)) / 2 v := tea.NewView(str) - v.Cursor = c + // v.Cursor = c v.AltScreen = true return v } @@ -343,9 +362,45 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) { var buttonStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Background(lipgloss.Color("8")) var buttonActiveStyle = buttonStyle.Background(lipgloss.Color("30")) +var keybindKeyStyle = lipgloss.NewStyle() +var keybindActionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + var sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30")) var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30")) +func (m model) GameplayFishingView(width int, height int) string { + help := keybindKeyStyle.Render("c") + keybindActionStyle.Render(" cast") + "\t" + keybindKeyStyle.Render("x") + keybindActionStyle.Render(" cancel") + + switch m.playerState { + case PlayerIdle: + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)), + help, + ) + case PlayerCasting: + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().MarginBottom(1).Render(m.spinner.View()+" waitin' for a bite"), + help, + ) + case PlayerCaught: + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().MarginBottom(1).Render(m.message), + help, + ) + case PlayerFailed: + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().MarginBottom(1).Foreground(lipgloss.Red).Render(m.message), + help, + ) + } + + return "bad player state" +} + func (m model) GameplayView() tea.View { var renderedTabs []string @@ -365,28 +420,33 @@ func (m model) GameplayView() tea.View { paddingY, paddingX := 1, 2 - content := lipgloss.NewStyle(). - Width(m.width-paddingX*2). - Height(m.height-2-paddingY*2). + maxWidth := min(m.width, 80) + maxHeight := min(m.height, 80) + + var content string + + if m.tab == TabFishin { + content = m.GameplayFishingView(maxWidth, maxHeight) + } + + wrapped := lipgloss.NewStyle(). + Width(maxWidth-paddingX*2). + Height(maxHeight-2-paddingY*2). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("30")). Padding(paddingY, paddingX). - Render(lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)), - buttonActiveStyle.Render("Cast"), - )) + Render(content) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") box := lipgloss.NewStyle(). - Width(m.width). - Height(m.height). + Width(maxWidth). + Height(maxHeight). Padding(paddingY, paddingX). Render(lipgloss.JoinVertical( lipgloss.Left, tabs, - content, + wrapped, footer, )) @@ -414,6 +474,8 @@ func (m model) GameplayView() tea.View { type GameplayKeyMap struct { CycleTab key.Binding AltCycleTab key.Binding + Cast key.Binding + Cancel key.Binding Interact key.Binding } @@ -426,6 +488,14 @@ var DefaultGameplayKeyMap = GameplayKeyMap{ key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "Cycle Tab Left"), ), + Cast: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "Cast"), + ), + Cancel: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "Cancel action."), + ), Interact: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "Interact with selected option"), @@ -464,6 +534,35 @@ var sayings = []string{ "Catching the wind in a net", } +type castFinishedMsg struct { + caught bool + err error +} + +func waitForCast( + ctx context.Context, + wait time.Duration, +) tea.Cmd { + return func() tea.Msg { + timer := time.NewTimer(wait) + defer timer.Stop() + + select { + case <-timer.C: + caught := rand.IntN(100) < 75 + + return castFinishedMsg{ + caught: caught, + } + + case <-ctx.Done(): + return castFinishedMsg{ + err: ctx.Err(), + } + } + } +} + func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd @@ -486,26 +585,53 @@ func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { } else { m.tab -= 1 } - case key.Matches(msg, DefaultGameplayKeyMap.Interact): + case key.Matches(msg, DefaultGameplayKeyMap.Cast): + m.playerState = PlayerCasting + // if m.active = { is_casting, err := m.DB.IsCasting(m.angler.ID) if is_casting { + m.playerState = PlayerIdle m.error = "looks like you are already casting somewhere" // TODO: clear after a timeout + } else { + m.cast, err = m.DB.CreateNewCast(m.angler.ID) + + if err != nil { + m.playerState = PlayerIdle + m.error = "unable to start your cast" + log.Print(err) + } else { + // Fishing can take anywhere from 2-5 seconds + minTime, maxTime := 2, 5 + timeToWait := time.Duration(rand.IntN(maxTime-minTime+1)+minTime) * time.Second + + cmds = append(cmds, waitForCast(m.ctx, timeToWait), m.spinner.Tick) + } } - - m.cast, err = m.DB.CreateNewCast(m.angler.ID) - - if err != nil { - m.error = "unable to start your cast" - // TODO: handle error - } - - // TODO: implement rest of casting logic - - // } } + case castFinishedMsg: + _, err := m.DB.EndCastByID(m.cast.ID) + + if err != nil { + m.error = "unable to end your cast" + } + + if msg.err != nil { + m.error = msg.err.Error() + return m, nil + } + + if msg.caught { + m.playerState = PlayerCaught + m.message = "Got one! Nice!" + } else { + m.playerState = PlayerFailed + m.message = sayings[rand.IntN(len(sayings))] + } + + return m, nil } cmds = append(cmds, cmd) @@ -515,7 +641,7 @@ func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { func (m model) gotoGameplayView() (model, tea.Cmd) { m.page = PageGameplay - return m, m.spinner.Tick + return m, nil } type GlobalKeyMap struct { @@ -579,19 +705,25 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti return func(s ssh.Session) (tea.Model, []tea.ProgramOption) { pty, _, _ := s.Pty() angler, _ := s.Context().Value("angler").(models.Angler) + ctx, cancel := context.WithCancel(s.Context()) m := model{ - term: pty.Term, - width: pty.Window.Width, - height: pty.Window.Height, - bg: "light", - DB: DB, - page: PageChooseName, - angler: angler, - tabs: []string{"fishin'", "wares", "dock", "settings"}, - tab: TabFishin, - ti: textinput.New(), - spinner: spinner.New(), + // using ssh context for now + // TODO: may make sens to use a custom one + ctx: ctx, + cancel: cancel, + term: pty.Term, + width: pty.Window.Width, + height: pty.Window.Height, + bg: "light", + DB: DB, + page: PageChooseName, + angler: angler, + tabs: []string{"fishin'", "wares", "dock", "settings"}, + tab: TabFishin, + ti: textinput.New(), + spinner: spinner.New(), + playerState: PlayerIdle, } if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) { @@ -603,10 +735,10 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("30")) m.spinner.Spinner = spinner.Jump - m.ti.SetVirtualCursor(false) + // m.ti.SetVirtualCursor(false) + m.ti.SetWidth(lipgloss.Width(chooseNamePrompt) - 3) m.ti.Focus() m.ti.CharLimit = 156 - m.ti.SetWidth(20) return m, []tea.ProgramOption{} } diff --git a/compose.yml b/compose.yml index 048bfa9..d98150c 100644 --- a/compose.yml +++ b/compose.yml @@ -9,6 +9,8 @@ services: COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET} DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ENV: ${ENV} + volumes: + - ./data:/data ports: - "22:22" - "9900:9900"