fishing mechanic in ssh and fixed rotating host key

This commit is contained in:
2026-09-17 01:19:57 -05:00
parent 7bdf1027a8
commit 2017e54bfa
2 changed files with 193 additions and 59 deletions
+160 -28
View File
@@ -6,8 +6,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"math/rand/v2"
"net" "net"
"os" "os"
"time"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner" "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 // The SSH server need its own keys, this will create a keypair in the
// given path if it doesn't exist yet. // given path if it doesn't exist yet.
// By default, it will create an ED25519 key. // 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 { wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
angler, err := db.GetAnglerByKey(key) angler, err := db.GetAnglerByKey(key)
@@ -207,6 +209,15 @@ const (
PageGameplay PageGameplay
) )
type playerState int
const (
PlayerIdle playerState = iota
PlayerCasting
PlayerCaught
PlayerFailed
)
type tab int type tab int
const ( const (
@@ -217,6 +228,8 @@ const (
) )
type model struct { type model struct {
ctx context.Context
cancel context.CancelFunc
profile string profile string
term string term string
width int width int
@@ -232,6 +245,8 @@ type model struct {
spinner spinner.Model spinner spinner.Model
active tea.Model active tea.Model
cast models.Cast cast models.Cast
playerState playerState
message string
} }
func (m model) Init() tea.Cmd { 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 { func (m model) ChooseNameView() tea.View {
var error string var error string
@@ -258,7 +275,9 @@ func (m model) ChooseNameView() tea.View {
error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error) 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( center := lipgloss.Place(
m.width, m.width,
@@ -284,15 +303,15 @@ func (m model) ChooseNameView() tea.View {
bottom, 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.Y += lipgloss.Height(prompt) + (m.height-3-lipgloss.Height(input))/2
c.X += (m.width - lipgloss.Width(input)) / 2 // c.X += (m.width - lipgloss.Width(input)) / 2
v := tea.NewView(str) v := tea.NewView(str)
v.Cursor = c // v.Cursor = c
v.AltScreen = true v.AltScreen = true
return v 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 buttonStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Background(lipgloss.Color("8"))
var buttonActiveStyle = buttonStyle.Background(lipgloss.Color("30")) 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 sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30"))
var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(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 { func (m model) GameplayView() tea.View {
var renderedTabs []string var renderedTabs []string
@@ -365,28 +420,33 @@ func (m model) GameplayView() tea.View {
paddingY, paddingX := 1, 2 paddingY, paddingX := 1, 2
content := lipgloss.NewStyle(). maxWidth := min(m.width, 80)
Width(m.width-paddingX*2). maxHeight := min(m.height, 80)
Height(m.height-2-paddingY*2).
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()). BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("30")). BorderForeground(lipgloss.Color("30")).
Padding(paddingY, paddingX). Padding(paddingY, paddingX).
Render(lipgloss.JoinVertical( Render(content)
lipgloss.Left,
lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)),
buttonActiveStyle.Render("Cast"),
))
footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)")
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Width(m.width). Width(maxWidth).
Height(m.height). Height(maxHeight).
Padding(paddingY, paddingX). Padding(paddingY, paddingX).
Render(lipgloss.JoinVertical( Render(lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
tabs, tabs,
content, wrapped,
footer, footer,
)) ))
@@ -414,6 +474,8 @@ func (m model) GameplayView() tea.View {
type GameplayKeyMap struct { type GameplayKeyMap struct {
CycleTab key.Binding CycleTab key.Binding
AltCycleTab key.Binding AltCycleTab key.Binding
Cast key.Binding
Cancel key.Binding
Interact key.Binding Interact key.Binding
} }
@@ -426,6 +488,14 @@ var DefaultGameplayKeyMap = GameplayKeyMap{
key.WithKeys("shift+tab"), key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Cycle Tab Left"), 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( Interact: key.NewBinding(
key.WithKeys("enter"), key.WithKeys("enter"),
key.WithHelp("enter", "Interact with selected option"), key.WithHelp("enter", "Interact with selected option"),
@@ -464,6 +534,35 @@ var sayings = []string{
"Catching the wind in a net", "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) { func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -486,26 +585,53 @@ func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) {
} else { } else {
m.tab -= 1 m.tab -= 1
} }
case key.Matches(msg, DefaultGameplayKeyMap.Interact): case key.Matches(msg, DefaultGameplayKeyMap.Cast):
m.playerState = PlayerCasting
// if m.active = { // if m.active = {
is_casting, err := m.DB.IsCasting(m.angler.ID) is_casting, err := m.DB.IsCasting(m.angler.ID)
if is_casting { if is_casting {
m.playerState = PlayerIdle
m.error = "looks like you are already casting somewhere" m.error = "looks like you are already casting somewhere"
// TODO: clear after a timeout // TODO: clear after a timeout
} } else {
m.cast, err = m.DB.CreateNewCast(m.angler.ID) m.cast, err = m.DB.CreateNewCast(m.angler.ID)
if err != nil { if err != nil {
m.playerState = PlayerIdle
m.error = "unable to start your cast" m.error = "unable to start your cast"
// TODO: handle error 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)
}
}
}
case castFinishedMsg:
_, err := m.DB.EndCastByID(m.cast.ID)
if err != nil {
m.error = "unable to end your cast"
} }
// TODO: implement rest of casting logic 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) 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) { func (m model) gotoGameplayView() (model, tea.Cmd) {
m.page = PageGameplay m.page = PageGameplay
return m, m.spinner.Tick return m, nil
} }
type GlobalKeyMap struct { type GlobalKeyMap struct {
@@ -579,8 +705,13 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti
return func(s ssh.Session) (tea.Model, []tea.ProgramOption) { return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, _ := s.Pty() pty, _, _ := s.Pty()
angler, _ := s.Context().Value("angler").(models.Angler) angler, _ := s.Context().Value("angler").(models.Angler)
ctx, cancel := context.WithCancel(s.Context())
m := model{ m := model{
// using ssh context for now
// TODO: may make sens to use a custom one
ctx: ctx,
cancel: cancel,
term: pty.Term, term: pty.Term,
width: pty.Window.Width, width: pty.Window.Width,
height: pty.Window.Height, height: pty.Window.Height,
@@ -592,6 +723,7 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti
tab: TabFishin, tab: TabFishin,
ti: textinput.New(), ti: textinput.New(),
spinner: spinner.New(), spinner: spinner.New(),
playerState: PlayerIdle,
} }
if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) { 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.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("30"))
m.spinner.Spinner = spinner.Jump 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.Focus()
m.ti.CharLimit = 156 m.ti.CharLimit = 156
m.ti.SetWidth(20)
return m, []tea.ProgramOption{} return m, []tea.ProgramOption{}
} }
+2
View File
@@ -9,6 +9,8 @@ services:
COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET} COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET}
DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ENV: ${ENV} ENV: ${ENV}
volumes:
- ./data:/data
ports: ports:
- "22:22" - "22:22"
- "9900:9900" - "9900:9900"