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
+191 -59
View File
@@ -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{}
}
+2
View File
@@ -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"