Files
go-fishing/cmd/server/main.go
T

746 lines
16 KiB
Go

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{}
}
}