package main import ( "context" "database/sql" "errors" "fmt" "log" "math/rand/v2" "net" "os" "time" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/ssh" "charm.land/wish/v2" "charm.land/wish/v2/activeterm" "charm.land/wish/v2/bubbletea" "charm.land/wish/v2/logging" gossh "golang.org/x/crypto/ssh" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/joho/godotenv" "golang.org/x/sync/errgroup" "jp.wtf/go-fishing/internal/db" "jp.wtf/go-fishing/internal/handlers" "jp.wtf/go-fishing/internal/migrations" "jp.wtf/go-fishing/internal/models" // "charm.land/log/v2" _ "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" "github.com/pressly/goose/v3" ) const ( ssh_host = "0.0.0.0" ssh_port = "22" api_port = "9900" ) func VersionMiddleware(version string) gin.HandlerFunc { return func(c *gin.Context) { c.Set("APIVersion", version) } } func main() { err := godotenv.Load() if err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatal("Failed to load .env: ", err.Error()) } g, ctx := errgroup.WithContext(context.Background()) ENVIRONMENT := os.Getenv("ENV") DB_CONNECTION_STRING, db_conn_string_exists := os.LookupEnv("DB_CONNECTION_STRING") COOKIE_SECRET, secret_env_exists := os.LookupEnv("COOKIE_STORE_SECRET") if !db_conn_string_exists { log.Fatal("DB_CONNECTION_STRING env var is required") } if !secret_env_exists { log.Fatal("COOKIE_STORE_SECRET env var is required") } pool, err := pgxpool.New(ctx, DB_CONNECTION_STRING) if err != nil { log.Fatal("Failed to connect to DB: ", err.Error()) } defer pool.Close() database := sqlx.NewDb(stdlib.OpenDBFromPool(pool), "pgx") if err := database.Ping(); err != nil { log.Fatal("Database is not reachable: ", err) } db := db.Database{Conn: database} goose.SetBaseFS(migrations.Files) if err := goose.SetDialect("postgres"); err != nil { log.Fatal("Failed to set goos dialect: ", err) } if err := goose.Up(database.DB, "."); err != nil { log.Fatal("Failed to execute migrations: ", err) } g.Go(func() error { if ENVIRONMENT == "PRODUCTION" { gin.SetMode(gin.ReleaseMode) } r := gin.Default() if ENVIRONMENT == "PRODUCTION" { r.TrustedPlatform = gin.PlatformFlyIO } else { r.SetTrustedProxies(nil) } store := cookie.NewStore([]byte(COOKIE_SECRET)) r.Use(sessions.Sessions("auth", store)) v1 := r.Group("/api/v1") { v1.Use(VersionMiddleware("v1")) handlers := handlers.Env{DB: &db} handlers.RegisterRoutes(v1) } return r.Run(fmt.Sprintf(":%s", api_port)) }) srv, err := wish.NewServer( // The address the server will listen to. wish.WithAddress(net.JoinHostPort(ssh_host, ssh_port)), // 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("/data/.ssh/ssh_host_ed25519_key"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { angler, err := db.GetAnglerByKey(key) if err != nil { if !errors.Is(sql.ErrNoRows, err) { return false } } else { ctx.SetValue("angler", angler) } return key.Type() == "ssh-ed25519" }), // Middlewares do something on a ssh.Session, and then call the next // middleware in the stack. wish.WithMiddleware( bubbletea.Middleware(teaHandler(&db)), activeterm.Middleware(), // Bubble Tea apps usually require a PTY. func(next ssh.Handler) ssh.Handler { return func(sess ssh.Session) { raw := sess.Context().Value("angler") if raw != nil { next(sess) } else { angler, err := db.CreateNewAnglerFromKey(sess.PublicKey()) if err != nil { // TODO: hangle error println("err", err.Error()) } sess.Context().SetValue("angler", angler) next(sess) } } }, // The last item in the chain is the first to be called. logging.Middleware(), ), ) if err != nil { log.Fatal("Could not start server", "error", err) } log.Print("Starting SSH server @", ssh_host, ":", ssh_port) g.Go(func() error { return srv.ListenAndServe() }) g.Go(func() error { <-ctx.Done() // one of the servers failed, // or SIGINT/SIGTERM happened return nil }) g.Wait() } type page int const ( PageChooseName page = iota PageGameplay ) type playerState int const ( PlayerIdle playerState = iota PlayerCasting PlayerCaught PlayerFailed ) type tab int const ( TabFishin tab = iota TabWares TabDock TabSettings ) type model struct { 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 { var cmd tea.Cmd var cmds []tea.Cmd // Pages that need initialization should be called here if we can start on them switch m.page { case PageGameplay: m, cmd = m.gotoGameplayView() cmds = append(cmds, cmd) } // default values return tea.Batch( append(cmds, tea.RequestBackgroundColor)..., ) } const chooseNamePrompt = "aye fresh meat, got a name laddie?" func (m model) ChooseNameView() tea.View { var error string if len(m.error) > 0 { error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error) } // m.ti.SetWidth(lipgloss.Width(chooseNamePrompt)) input := lipgloss.JoinVertical(lipgloss.Top, chooseNamePrompt, m.ti.View(), error) center := lipgloss.Place( m.width, m.height-3, lipgloss.Center, lipgloss.Center, input, ) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") bottom := lipgloss.Place( m.width, 3, lipgloss.Center, lipgloss.Center, footer, ) str := lipgloss.JoinVertical( lipgloss.Left, center, bottom, ) // var c *tea.Cursor // c = m.ti.Cursor() // 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.AltScreen = true return v } func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: m.error = "" switch msg.String() { case "enter": existing := 0 desired_username := m.ti.Value() err := m.DB.Conn.Get(&existing, "SELECT 1 FROM anglers WHERE angler_username = $1", desired_username) if err != nil { // TODO: handle error } if existing > 0 { m.error = "that one is taken" } else { err = m.DB.Conn.Get( &m.angler, "UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email", desired_username, m.angler.ID, ) if err != nil { // TODO: handle error } m, cmd = m.gotoGameplayView() cmds = append(cmds, cmd) } } } m.ti.Placeholder = m.angler.Username m.ti, cmd = m.ti.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } 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 for i, tab := range m.tabs { if i == int(m.tab) { renderedTabs = append(renderedTabs, sidebarBtnActiveStyle.Render(tab)) } else { renderedTabs = append(renderedTabs, sidebarBtnStyle.Render(tab)) } } tabs := lipgloss.NewStyle().Margin(0, 1).Render(lipgloss.JoinHorizontal( lipgloss.Top, renderedTabs..., )) paddingY, paddingX := 1, 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(content) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") box := lipgloss.NewStyle(). Width(maxWidth). Height(maxHeight). Padding(paddingY, paddingX). Render(lipgloss.JoinVertical( lipgloss.Left, tabs, wrapped, footer, )) page := lipgloss.Place( m.width, m.height-1, lipgloss.Center, lipgloss.Center, box, ) // bottom := lipgloss.Place( // m.width, // 3, // lipgloss.Center, // lipgloss.Center, // footer, // ) v := tea.NewView(page) v.AltScreen = true return v } type GameplayKeyMap struct { CycleTab key.Binding AltCycleTab key.Binding Cast key.Binding Cancel key.Binding Interact key.Binding } var DefaultGameplayKeyMap = GameplayKeyMap{ CycleTab: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "Cycle Tab Right"), ), AltCycleTab: key.NewBinding( 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"), ), } var sayings = []string{ // Near Misses "Close but no cigar", "Almost only counts in horseshoes and hand grenades", "A day late and a dollar short", "A miss is as good as a mile", "Always a bridesmaid, never a bride", "Snatched defeat from the jaws of victory", "Fell at the last hurdle", "Missed the boat", "Missed it by a whisker", "So near, and yet so far", "A swing and a miss", // Futile Efforts "Beating a dead horse", "Tilting at windmills", "Squeezing blood from a stone", "Nailing jelly to a wall", "Herding cats", "Rearranging deck chairs on the Titanic", "Pushing a rope", "Spitting in the wind", "Putting toothpaste back in the tube", "Shoveling snow in a blizzard", "Spinning your wheels", "Emptying the ocean with a teaspoon", "Barking up the wrong tree", "A wild goose chase", "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 switch msg := msg.(type) { case spinner.TickMsg: m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) case tea.KeyPressMsg: switch { case key.Matches(msg, DefaultGameplayKeyMap.CycleTab): if int(m.tab) >= len(m.tabs)-1 { m.tab = 0 } else { m.tab += 1 } case key.Matches(msg, DefaultGameplayKeyMap.AltCycleTab): if int(m.tab) <= 0 { m.tab = tab(len(m.tabs) - 1) } else { m.tab -= 1 } 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) } } } 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) return m, tea.Batch(cmds...) } func (m model) gotoGameplayView() (model, tea.Cmd) { m.page = PageGameplay return m, nil } type GlobalKeyMap struct { Close key.Binding } var DefaultGlobalKeyMap = GlobalKeyMap{ Close: key.NewBinding( key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "Quit"), ), } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.ColorProfileMsg: m.profile = msg.String() case tea.BackgroundColorMsg: if msg.IsDark() { m.bg = "dark" } case tea.WindowSizeMsg: m.height = msg.Height m.width = msg.Width case tea.KeyPressMsg: switch { case key.Matches(msg, DefaultGlobalKeyMap.Close): return m, tea.Quit } } switch m.page { case PageChooseName: m, cmd = m.ChooseNameUpdate(msg) cmds = append(cmds, cmd) case PageGameplay: m, cmd = m.GameplayUpdate(msg) cmds = append(cmds, cmd) } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() tea.View { switch m.page { case PageChooseName: return m.ChooseNameView() case PageGameplay: return m.GameplayView() } return tea.NewView("") } func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOption) { 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{ // 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()) { m.page = PageChooseName } else { m.page = PageGameplay } m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("30")) m.spinner.Spinner = spinner.Jump // m.ti.SetVirtualCursor(false) m.ti.SetWidth(lipgloss.Width(chooseNamePrompt) - 3) m.ti.Focus() m.ti.CharLimit = 156 return m, []tea.ProgramOption{} } }