hermeticum/client/cmd/main.go

334 lines
8.3 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/vilmibm/hermeticum/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
tls = flag.Bool("tls", false, "Connection uses TLS if true, else plain TCP")
caFile = flag.String("ca_file", "", "The file containing the CA root cert file")
serverAddr = flag.String("addr", "localhost:6666", "The server address in the format of host:port")
serverHostOverride = flag.String("server_host_override", "x.test.example.com", "The server name used to verify the hostname returned by the TLS handshake")
)
type ClientState struct {
App *tview.Application
Client proto.GameWorldClient
SessionInfo *proto.SessionInfo
MaxMessages int
messagesView *tview.TextView
messages []*proto.ClientMessage
cmdStream proto.GameWorld_CommandsClient
}
func (cs *ClientState) Messages() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := cs.Client.Messages(ctx, cs.SessionInfo)
if err != nil {
return err
}
for {
msg, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
cs.AddMessage(msg)
}
return nil
}
func (cs *ClientState) HandleSIGINT(sigC chan os.Signal) {
for range sigC {
cm := &proto.Command{
SessionInfo: cs.SessionInfo,
Verb: "quit",
}
err := cs.cmdStream.Send(cm)
if err != nil {
fmt.Printf("failed to send quit verb to server: %s\n", err.Error())
}
_, err = cs.cmdStream.Recv()
if err != nil {
fmt.Printf("failed to receive an ACK from server: %s\n", err.Error())
}
cs.App.Stop()
}
}
func (cs *ClientState) HandleInput(input string) error {
var verb string
rest := input
if strings.HasPrefix(input, "/") {
verb, rest, _ = strings.Cut(input[1:], " ")
} else {
verb = "say"
}
cmd := &proto.Command{
SessionInfo: cs.SessionInfo,
Verb: verb,
Rest: rest,
}
// TODO I'm punting on handling CommandAcks for now but it will be a nice UX thing later for showing connectivity problems
err := cs.cmdStream.Send(cmd)
if err != nil {
return err
}
_, err = cs.cmdStream.Recv()
if err != nil {
fmt.Printf("failed to receive an ACK from server: %s\n", err.Error())
}
if verb == "quit" || verb == "q" {
cs.App.Stop()
}
return nil
}
func (cs *ClientState) InitCommandStream() error {
ctx := context.Background()
stream, err := cs.Client.Commands(ctx)
if err != nil {
return err
}
cs.cmdStream = stream
return nil
}
func (cs *ClientState) AddMessage(msg *proto.ClientMessage) {
// TODO i don't like this function
cs.messages = append(cs.messages, msg)
if len(cs.messages) > cs.MaxMessages {
cs.messages = cs.messages[1 : len(cs.messages)-1]
}
// TODO look into using the SetChangedFunc thing.
cs.App.QueueUpdateDraw(func() {
// TODO trim content of messagesView /or/ see if tview has a buffer size that does it for me. use cs.messages to re-constitute.
switch msg.Type {
case proto.ClientMessage_OVERHEARD:
fmt.Fprintf(cs.messagesView, "%s: %s\n", msg.GetSpeaker(), msg.GetText())
case proto.ClientMessage_EMOTE:
fmt.Fprintf(cs.messagesView, "%s %s\n", msg.GetSpeaker(), msg.GetText())
default:
fmt.Fprintf(cs.messagesView, "%#v\n", msg)
}
cs.messagesView.ScrollToEnd()
})
}
func _main() error {
var opts []grpc.DialOption
if *tls {
return errors.New("TODO tls unsupported")
/*
if *caFile == "" {
*caFile = data.Path("x509/ca_cert.pem")
}
creds, err := credentials.NewClientTLSFromFile(*caFile, *serverHostOverride)
if err != nil {
log.Fatalf("Failed to create TLS credentials %v", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
*/
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
conn, err := grpc.Dial(*serverAddr, opts...)
if err != nil {
return fmt.Errorf("fail to dial: %w", err)
}
defer conn.Close()
client := proto.NewGameWorldClient(conn)
app := tview.NewApplication()
// TODO make a NewClientState
// TODO rename this, like, UI
cs := &ClientState{
App: app,
SessionInfo: &proto.SessionInfo{},
Client: client,
MaxMessages: 15, // TODO for testing
messages: []*proto.ClientMessage{},
}
err = cs.InitCommandStream()
if err != nil {
return fmt.Errorf("could not create command stream: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = cs.Client.Ping(ctx, cs.SessionInfo); err != nil {
log.Fatalf("%v.Ping -> %v", cs.Client, err)
}
pages := tview.NewPages()
pages.AddPage("splash",
tview.NewModal().
AddButtons([]string{"hey. let's go"}).
SetDoneFunc(func(_ int, _ string) {
pages.SwitchToPage("main")
app.ResizeToFullScreen(pages)
}).SetText("h e r m e t i c u m"),
true,
true)
commandInput := tview.NewInputField().SetLabel("> ")
handleInput := func(_ tcell.Key) {
input := commandInput.GetText()
// TODO command history
commandInput.SetText("")
// TODO do i need to clear the input's text?
go cs.HandleInput(input)
}
commandInput.SetDoneFunc(handleInput)
mainPage := tview.NewList().
AddItem("jack in", "connect using an existing account", '1', func() {
pages.SwitchToPage("login")
}).
AddItem("rez a toon", "create a new account", '2', func() {
pages.SwitchToPage("register")
}).
AddItem("open the hood", "client configuration", '3', nil).
AddItem("get outta here", "quit the client", '4', func() {
app.Stop()
})
pages.AddPage("main", mainPage, true, false)
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, os.Interrupt)
lunfi := tview.NewInputField().SetLabel("account name")
lpwfi := tview.NewInputField().SetLabel("password").SetMaskCharacter('~')
loginPage := tview.NewForm().AddFormItem(lunfi).AddFormItem(lpwfi).
SetCancelFunc(func() {
pages.SwitchToPage("main")
})
loginSubmitFn := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
si, err := cs.Client.Login(ctx, &proto.AuthInfo{
Username: lunfi.GetText(),
Password: lpwfi.GetText(),
})
if err != nil {
panic(err.Error())
}
cs.SessionInfo = si
pages.SwitchToPage("game")
app.SetFocus(commandInput)
go cs.HandleSIGINT(sigC)
// TODO error handle
go cs.Messages()
}
// TODO login and register pages should refuse blank entries
// TODO password should have rules
loginPage.AddButton("gimme that shit", loginSubmitFn)
loginPage.AddButton("nah get outta here", func() {
pages.SwitchToPage("main")
})
pages.AddPage("login", loginPage, true, false)
runfi := tview.NewInputField().SetLabel("account name")
rpwfi := tview.NewInputField().SetLabel("password").SetMaskCharacter('~')
registerPage := tview.NewForm().AddFormItem(runfi).AddFormItem(rpwfi).
SetCancelFunc(func() {
pages.SwitchToPage("main")
})
registerSubmitFn := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
si, err := cs.Client.Register(ctx, &proto.AuthInfo{
Username: runfi.GetText(),
Password: rpwfi.GetText(),
})
if err != nil {
panic(err.Error())
}
cs.SessionInfo = si
pages.SwitchToPage("game")
app.SetFocus(commandInput)
go cs.HandleSIGINT(sigC)
// TODO error handle
go cs.Messages()
}
registerPage.AddButton("gimme that shit", registerSubmitFn)
registerPage.AddButton("nah get outta here", func() {
pages.SwitchToPage("main")
})
pages.AddPage("register", registerPage, true, false)
msgView := tview.NewTextView().SetScrollable(true).SetWrap(true).SetWordWrap(true)
cs.messagesView = msgView
gamePage := tview.NewGrid().
SetRows(1, 40, 3).
SetColumns(-1, -1).
SetBorders(true).
AddItem(
tview.NewTextView().SetTextAlign(tview.AlignLeft).SetText("h e r m e t i c u m"),
0, 0, 1, 1, 1, 1, false).
AddItem(
tview.NewTextView().SetTextAlign(tview.AlignRight).SetText("TODO server status"),
0, 1, 1, 1, 1, 1, false).
AddItem(
msgView,
1, 0, 1, 1, 10, 20, false).
AddItem(
tview.NewTextView().SetText("TODO details"),
1, 1, 1, 1, 10, 10, false).
AddItem(
commandInput,
2, 0, 1, 2, 1, 30, false)
pages.AddPage("game", gamePage, true, false)
return app.SetRoot(pages, true).SetFocus(pages).Run()
}
func main() {
err := _main()
if err != nil {
log.Fatal(err.Error())
}
}