2022-07-07 22:07:03 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2022-12-14 07:52:29 +00:00
|
|
|
"errors"
|
2022-07-07 22:07:03 +00:00
|
|
|
"flag"
|
|
|
|
"fmt"
|
2022-07-22 21:10:12 +00:00
|
|
|
"io"
|
2022-07-07 22:07:03 +00:00
|
|
|
"log"
|
|
|
|
"net"
|
2022-07-22 21:57:15 +00:00
|
|
|
"sync"
|
2022-07-07 22:07:03 +00:00
|
|
|
|
|
|
|
"github.com/vilmibm/hermeticum/proto"
|
2022-07-16 05:56:12 +00:00
|
|
|
"github.com/vilmibm/hermeticum/server/db"
|
2022-12-20 08:38:47 +00:00
|
|
|
"github.com/vilmibm/hermeticum/server/witch"
|
2022-07-07 22:07:03 +00:00
|
|
|
"google.golang.org/grpc"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
tls = flag.Bool("tls", false, "Connection uses TLS if true, else plain TCP")
|
|
|
|
certFile = flag.String("cert_file", "", "The TLS cert file")
|
|
|
|
keyFile = flag.String("key_file", "", "The TLS key file")
|
|
|
|
port = flag.Int("port", 6666, "The server port")
|
|
|
|
)
|
|
|
|
|
2022-12-15 08:14:22 +00:00
|
|
|
/*
|
|
|
|
I'm going to take a much simpler approach to scripts than I did in tildemush: objects just get one text column with no revision tracking. to avoid re-parsing scripts per verb check (as every overheard verb has to be checked against an object's script every verb utterance) i want an in-memory cache of lua states. i'm not actually sure if the goroutine unsafety is a problem for that. if it is, i can put goroutines in memory and send them verbs over channels. annoying, but should work if i have to. going to start with a naive map of object ids to scripts, re-parsing them if they get edited and updating the cache.
|
|
|
|
|
|
|
|
The cache will grow without bound as users enter rooms with objects. they ought to be garbage collected. i can do that in a goroutine though (check DB for objects in rooms with no players). One complication will be when I have "cron" support for objects. they will need to be "live" (ie, their scripts executable) in order to do their periodic tasks.
|
|
|
|
|
|
|
|
An idea I just had for a cron system: respond to a "tick" verb. at server start, once all in-world objects get parsed, start emitting "tick" events from a for loop in a goroutine in rooms with objects. this can be optimized for having a way to flag periodic-able objects so they don't get the verb if they wouldn't respond.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
2022-07-07 22:07:03 +00:00
|
|
|
func _main() (err error) {
|
|
|
|
l, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
fmt.Printf("DBG %#v\n", l)
|
|
|
|
|
|
|
|
var opts []grpc.ServerOption
|
|
|
|
if *tls {
|
|
|
|
log.Fatal("tls unsupported")
|
|
|
|
/*
|
|
|
|
// TODO base some stuff on the data package in the examples to get tls working
|
|
|
|
if *certFile == "" {
|
|
|
|
*certFile = data.Path("x509/server_cert.pem")
|
|
|
|
}
|
|
|
|
if *keyFile == "" {
|
|
|
|
*keyFile = data.Path("x509/server_key.pem")
|
|
|
|
}
|
|
|
|
creds, err := credentials.NewServerTLSFromFile(*certFile, *keyFile)
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("Failed to generate credentials %v", err)
|
|
|
|
}
|
|
|
|
opts = []grpc.ServerOption{grpc.Creds(creds)}
|
|
|
|
*/
|
|
|
|
}
|
|
|
|
grpcServer := grpc.NewServer(opts...)
|
2022-07-28 01:45:21 +00:00
|
|
|
srv, err := newServer()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
proto.RegisterGameWorldServer(grpcServer, srv)
|
2022-07-07 22:07:03 +00:00
|
|
|
grpcServer.Serve(l)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type gameWorldServer struct {
|
|
|
|
proto.UnimplementedGameWorldServer
|
2022-07-22 21:57:15 +00:00
|
|
|
|
2022-12-24 05:31:08 +00:00
|
|
|
db db.DB
|
|
|
|
msgRouterMutex sync.Mutex
|
|
|
|
msgRouter map[string]func(*proto.ClientMessage) error
|
|
|
|
scripts map[int]*witch.ScriptContext
|
|
|
|
scriptsMutex sync.RWMutex
|
2022-07-07 22:07:03 +00:00
|
|
|
}
|
|
|
|
|
2022-07-28 01:45:21 +00:00
|
|
|
func newServer() (*gameWorldServer, error) {
|
|
|
|
// TODO read from env or whatever
|
|
|
|
db, err := db.NewDB("postgres://vilmibm:vilmibm@localhost:5432/hermeticum")
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-12-23 06:03:37 +00:00
|
|
|
if err = db.Ensure(); err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to ensure default entities: %w", err)
|
|
|
|
}
|
|
|
|
|
2022-12-14 07:52:29 +00:00
|
|
|
if err = db.ClearSessions(); err != nil {
|
|
|
|
return nil, fmt.Errorf("could not clear sessions: %w", err)
|
|
|
|
}
|
|
|
|
|
2022-07-22 21:57:15 +00:00
|
|
|
s := &gameWorldServer{
|
2022-12-24 05:31:08 +00:00
|
|
|
msgRouter: make(map[string]func(*proto.ClientMessage) error),
|
|
|
|
db: db,
|
|
|
|
scripts: make(map[int]*witch.ScriptContext),
|
|
|
|
scriptsMutex: sync.RWMutex{},
|
2022-07-22 21:57:15 +00:00
|
|
|
}
|
2022-07-28 01:45:21 +00:00
|
|
|
|
|
|
|
return s, nil
|
2022-07-07 22:07:03 +00:00
|
|
|
}
|
|
|
|
|
2022-12-24 05:31:08 +00:00
|
|
|
func (s *gameWorldServer) verbHandler(verb, rest string, sender, target db.Object) error {
|
|
|
|
s.scriptsMutex.RLock()
|
|
|
|
sc, ok := s.scripts[target.ID]
|
|
|
|
s.scriptsMutex.RUnlock()
|
2022-12-24 06:34:21 +00:00
|
|
|
var err error
|
2022-12-24 05:31:08 +00:00
|
|
|
|
2022-12-28 05:19:42 +00:00
|
|
|
sid, _ := s.db.SessionIDForAvatar(target)
|
|
|
|
tell := func(_ int, _ string) {}
|
|
|
|
if sid != "" {
|
|
|
|
send := s.msgRouter[sid]
|
|
|
|
tell = func(senderID int, msg string) {
|
|
|
|
senderName := "a mysterious stranger"
|
|
|
|
|
|
|
|
sender, err := s.db.GetObjectByID(senderID)
|
|
|
|
if err == nil {
|
|
|
|
senderName = sender.Data["name"]
|
|
|
|
}
|
|
|
|
|
|
|
|
cm := proto.ClientMessage{
|
|
|
|
Type: proto.ClientMessage_OVERHEARD,
|
|
|
|
Text: msg,
|
|
|
|
Speaker: &senderName,
|
|
|
|
}
|
|
|
|
|
|
|
|
send(&cm)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-24 06:34:21 +00:00
|
|
|
if !ok {
|
2022-12-28 05:19:42 +00:00
|
|
|
sc, err = witch.NewScriptContext(tell)
|
2022-12-24 05:31:08 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
s.scriptsMutex.Lock()
|
|
|
|
s.scripts[target.ID] = sc
|
|
|
|
s.scriptsMutex.Unlock()
|
|
|
|
}
|
|
|
|
|
2022-12-24 06:34:21 +00:00
|
|
|
vc := witch.VerbContext{
|
|
|
|
Verb: verb,
|
|
|
|
Rest: rest,
|
|
|
|
Sender: sender,
|
|
|
|
Target: target,
|
|
|
|
}
|
|
|
|
|
|
|
|
sc.Handle(vc)
|
|
|
|
|
|
|
|
return nil
|
2022-12-24 05:31:08 +00:00
|
|
|
}
|
|
|
|
|
2022-12-23 06:03:37 +00:00
|
|
|
func (s *gameWorldServer) HandleCmd(verb, rest string, sender *db.Object) {
|
|
|
|
// TODO
|
|
|
|
}
|
|
|
|
|
2022-12-23 06:55:35 +00:00
|
|
|
/*
|
|
|
|
what's the flow for when i'm at a computer and type /say hi ?
|
|
|
|
|
|
|
|
- server gets "SAY hi" from vilmibm
|
|
|
|
- server gets all objects in earshot (including vilmibm's avatar)
|
|
|
|
- for each object:
|
|
|
|
- call whatever handler it has for "hears"
|
|
|
|
|
|
|
|
and then that's it, right? over in witch land:
|
|
|
|
|
|
|
|
- hears handler for an avatar has:
|
|
|
|
|
|
|
|
tellMe(sender.get("name") + " says " + msg)
|
|
|
|
|
|
|
|
- tellMe somehow calls a method on the gameWorldServer that can look up a
|
|
|
|
session ID and thus use the msgRouter to send a message. I'm going to sleep
|
|
|
|
on this so I can think about the right way to structure those dependencies.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
2022-07-22 21:10:12 +00:00
|
|
|
func (s *gameWorldServer) Commands(stream proto.GameWorld_CommandsServer) error {
|
2022-07-28 01:45:21 +00:00
|
|
|
var sid string
|
2022-07-22 21:10:12 +00:00
|
|
|
for {
|
|
|
|
cmd, err := stream.Recv()
|
|
|
|
if err == io.EOF {
|
2022-07-28 02:05:48 +00:00
|
|
|
// TODO this doesn't really do anything. if a client
|
|
|
|
// disconnects without warning there's no EOF.
|
2022-07-28 01:45:21 +00:00
|
|
|
return s.db.EndSession(sid)
|
2022-07-22 21:10:12 +00:00
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-07-28 01:45:21 +00:00
|
|
|
sid = cmd.SessionInfo.SessionID
|
2022-07-28 02:05:48 +00:00
|
|
|
|
|
|
|
log.Printf("verb %s in session %s", cmd.Verb, sid)
|
|
|
|
|
|
|
|
if cmd.Verb == "quit" || cmd.Verb == "q" {
|
|
|
|
s.msgRouter[sid] = nil
|
|
|
|
log.Printf("ending session %s", sid)
|
|
|
|
return s.db.EndSession(sid)
|
|
|
|
}
|
2022-07-22 21:57:15 +00:00
|
|
|
send := s.msgRouter[sid]
|
|
|
|
|
2022-12-14 07:52:29 +00:00
|
|
|
// TODO what is the implication of returning an error from this function?
|
2022-07-22 21:57:15 +00:00
|
|
|
|
2022-12-14 07:52:29 +00:00
|
|
|
avatar, err := s.db.AvatarBySessionID(sid)
|
2022-07-22 21:57:15 +00:00
|
|
|
if err != nil {
|
2022-12-14 07:52:29 +00:00
|
|
|
return s.HandleError(send, err)
|
2022-07-22 21:57:15 +00:00
|
|
|
}
|
2022-12-14 07:52:29 +00:00
|
|
|
log.Printf("found avatar %#v", avatar)
|
2022-07-22 21:10:12 +00:00
|
|
|
|
2022-12-23 06:55:35 +00:00
|
|
|
affected, err := s.db.Earshot(*avatar)
|
|
|
|
|
|
|
|
for _, o := range affected {
|
2022-12-24 06:34:21 +00:00
|
|
|
err = s.verbHandler(cmd.Verb, cmd.Rest, *avatar, o)
|
2022-12-23 06:55:35 +00:00
|
|
|
}
|
|
|
|
|
2022-12-24 06:34:21 +00:00
|
|
|
//s.HandleCmd(cmd.Verb, cmd.Rest, avatar)
|
2022-12-23 06:03:37 +00:00
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
|
|
switch cmd.Verb {
|
|
|
|
case "say":
|
|
|
|
if err = s.HandleSay(avatar, cmd.Rest); err != nil {
|
|
|
|
s.HandleError(func(_ *proto.ClientMessage) error { return nil }, err)
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
msg := &proto.ClientMessage{
|
|
|
|
Type: proto.ClientMessage_WHISPER,
|
|
|
|
Text: fmt.Sprintf("unknown verb: %s", cmd.Verb),
|
|
|
|
}
|
|
|
|
if err = send(msg); err != nil {
|
|
|
|
s.HandleError(send, err)
|
|
|
|
}
|
2022-12-14 07:52:29 +00:00
|
|
|
}
|
2022-12-23 06:03:37 +00:00
|
|
|
|
|
|
|
*/
|
2022-12-14 07:52:29 +00:00
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
|
|
msg := &proto.ClientMessage{
|
|
|
|
Type: proto.ClientMessage_OVERHEARD,
|
|
|
|
Text: fmt.Sprintf("%s sent command %s with args %s",
|
|
|
|
sid, cmd.Verb, cmd.Rest),
|
|
|
|
}
|
|
|
|
|
|
|
|
speaker := "ECHO"
|
|
|
|
msg.Speaker = &speaker
|
|
|
|
|
|
|
|
err = send(msg)
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("failed to send %v to %s: %s", msg, sid, err)
|
|
|
|
}
|
|
|
|
*/
|
2022-07-22 21:10:12 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-07-07 22:07:03 +00:00
|
|
|
func (s *gameWorldServer) Ping(ctx context.Context, _ *proto.SessionInfo) (*proto.Pong, error) {
|
|
|
|
pong := &proto.Pong{
|
|
|
|
When: "TODO",
|
|
|
|
}
|
|
|
|
|
|
|
|
return pong, nil
|
|
|
|
}
|
|
|
|
|
2022-07-09 07:15:24 +00:00
|
|
|
func (s *gameWorldServer) Messages(si *proto.SessionInfo, stream proto.GameWorld_MessagesServer) error {
|
2022-12-24 05:31:08 +00:00
|
|
|
s.msgRouterMutex.Lock()
|
2022-07-22 21:57:15 +00:00
|
|
|
s.msgRouter[si.SessionID] = stream.Send
|
2022-12-24 05:31:08 +00:00
|
|
|
s.msgRouterMutex.Unlock()
|
2022-07-22 21:57:15 +00:00
|
|
|
|
|
|
|
// TODO this is clearly bad but it works. I should refactor this so that messages are received on a channel.
|
|
|
|
for {
|
2022-07-09 07:15:24 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-01 16:00:43 +00:00
|
|
|
// TODO make sure the Foyer is created as part of initial setup / migration
|
|
|
|
|
2022-07-16 05:56:12 +00:00
|
|
|
func (s *gameWorldServer) Register(ctx context.Context, auth *proto.AuthInfo) (si *proto.SessionInfo, err error) {
|
2022-07-28 03:30:23 +00:00
|
|
|
var account *db.Account
|
|
|
|
account, err = s.db.CreateAccount(auth.Username, auth.Password)
|
2022-07-16 05:56:12 +00:00
|
|
|
if err != nil {
|
2022-07-28 03:30:23 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-08-01 15:15:18 +00:00
|
|
|
var sessionID string
|
|
|
|
sessionID, err = s.db.StartSession(*account)
|
2022-07-28 03:30:23 +00:00
|
|
|
if err != nil {
|
2022-08-01 16:00:43 +00:00
|
|
|
return nil, fmt.Errorf("failed to start session for %d: %w", account.ID, err)
|
2022-07-16 05:56:12 +00:00
|
|
|
}
|
2022-08-01 15:15:18 +00:00
|
|
|
log.Printf("started session for %s", account.Name)
|
2022-07-16 05:56:12 +00:00
|
|
|
|
2022-08-01 15:15:18 +00:00
|
|
|
av, err := s.db.AvatarBySessionID(sessionID)
|
|
|
|
if err != nil {
|
2022-08-01 16:00:43 +00:00
|
|
|
return nil, fmt.Errorf("failed to find avatar for %s: %w", sessionID, err)
|
2022-08-01 15:15:18 +00:00
|
|
|
}
|
2022-07-28 03:30:23 +00:00
|
|
|
|
2022-12-23 06:03:37 +00:00
|
|
|
/*
|
|
|
|
bedroom, err := s.db.BedroomBySessionID(sessionID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to find bedroom for %s: %w", sessionID, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = s.db.MoveInto(*av, *bedroom)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to move %d into %d: %w", av.ID, bedroom.ID, err)
|
|
|
|
}
|
|
|
|
*/
|
|
|
|
|
|
|
|
foyer, err := s.db.GetObject("system", "foyer")
|
2022-08-01 15:15:18 +00:00
|
|
|
if err != nil {
|
2022-12-23 06:03:37 +00:00
|
|
|
return nil, fmt.Errorf("failed to find foyer: %w", err)
|
2022-08-01 15:15:18 +00:00
|
|
|
}
|
2022-07-28 03:30:23 +00:00
|
|
|
|
2022-12-23 06:03:37 +00:00
|
|
|
if err = s.db.MoveInto(*av, *foyer); err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to move %d into %d: %w", av.ID, foyer.ID, err)
|
2022-07-16 05:56:12 +00:00
|
|
|
}
|
2022-08-01 15:15:18 +00:00
|
|
|
|
|
|
|
// TODO send room info, avatar info to client (need to figure this out and update proto)
|
2022-07-16 05:56:12 +00:00
|
|
|
|
|
|
|
si = &proto.SessionInfo{SessionID: sessionID}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *gameWorldServer) Login(ctx context.Context, auth *proto.AuthInfo) (si *proto.SessionInfo, err error) {
|
2022-07-16 06:54:18 +00:00
|
|
|
var a *db.Account
|
2022-07-28 01:45:21 +00:00
|
|
|
a, err = s.db.ValidateCredentials(auth.Username, auth.Password)
|
2022-07-16 05:56:12 +00:00
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var sessionID string
|
2022-07-28 01:45:21 +00:00
|
|
|
sessionID, err = s.db.StartSession(*a)
|
2022-07-16 05:56:12 +00:00
|
|
|
if err != nil {
|
2022-07-16 06:54:18 +00:00
|
|
|
return
|
2022-07-16 05:56:12 +00:00
|
|
|
}
|
|
|
|
|
2022-12-14 07:52:29 +00:00
|
|
|
av, err := s.db.AvatarBySessionID(sessionID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to find avatar for %s: %w", sessionID, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
bedroom, err := s.db.BedroomBySessionID(sessionID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to find bedroom for %s: %w", sessionID, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = s.db.MoveInto(*av, *bedroom)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to move %d into %d: %w", av.ID, bedroom.ID, err)
|
|
|
|
}
|
|
|
|
|
2022-07-16 05:56:12 +00:00
|
|
|
si = &proto.SessionInfo{SessionID: sessionID}
|
|
|
|
|
|
|
|
return
|
2022-07-07 22:07:03 +00:00
|
|
|
}
|
|
|
|
|
2022-12-20 08:38:47 +00:00
|
|
|
func (s *gameWorldServer) HandleSay(sender *db.Object, msg string) error {
|
|
|
|
name := sender.Data["name"]
|
2022-12-14 07:52:29 +00:00
|
|
|
if name == "" {
|
|
|
|
// TODO determine this based on a hash or something
|
|
|
|
name = "a mysterious figure"
|
|
|
|
}
|
|
|
|
|
2022-12-20 08:38:47 +00:00
|
|
|
heard, err := s.db.Earshot(*sender)
|
2022-12-14 07:52:29 +00:00
|
|
|
if err != nil {
|
|
|
|
log.Println(err.Error())
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-12-20 08:38:47 +00:00
|
|
|
log.Printf("found %#v in earshot of %#v\n", heard, sender)
|
2022-12-14 07:52:29 +00:00
|
|
|
|
|
|
|
as, err := s.db.ActiveSessions()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
sendErrs := []error{}
|
|
|
|
|
2022-12-22 06:38:15 +00:00
|
|
|
// TODO figure out pointer shit
|
|
|
|
|
2022-12-14 07:52:29 +00:00
|
|
|
for _, h := range heard {
|
2022-12-24 06:34:21 +00:00
|
|
|
s.verbHandler("hears", msg, *sender, h)
|
2022-12-14 07:52:29 +00:00
|
|
|
// TODO once we have a script engine, deliver the HEARS event
|
|
|
|
for _, sess := range as {
|
|
|
|
if sess.AccountID == h.OwnerID {
|
|
|
|
cm := proto.ClientMessage{
|
|
|
|
Type: proto.ClientMessage_OVERHEARD,
|
|
|
|
Text: msg,
|
|
|
|
Speaker: &name,
|
|
|
|
}
|
|
|
|
err = s.msgRouter[sess.ID](&cm)
|
|
|
|
if err != nil {
|
|
|
|
sendErrs = append(sendErrs, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(sendErrs) > 0 {
|
|
|
|
errMsg := "send errors: "
|
|
|
|
for i, err := range sendErrs {
|
|
|
|
errMsg += err.Error()
|
|
|
|
if i < len(sendErrs)-1 {
|
|
|
|
errMsg += ", "
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return errors.New(errMsg)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *gameWorldServer) HandleError(send func(*proto.ClientMessage) error, err error) error {
|
|
|
|
log.Printf("error: %s", err.Error())
|
|
|
|
msg := &proto.ClientMessage{
|
|
|
|
Type: proto.ClientMessage_WHISPER,
|
|
|
|
Text: "server error :(",
|
|
|
|
}
|
|
|
|
err = send(msg)
|
|
|
|
if err != nil {
|
|
|
|
log.Printf("error sending to client: %s", err.Error())
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-07-07 22:07:03 +00:00
|
|
|
// TODO other server functions
|
|
|
|
|
|
|
|
func main() {
|
2022-08-01 16:00:43 +00:00
|
|
|
// TODO at some point during startup clear out sessions
|
2022-07-07 22:07:03 +00:00
|
|
|
err := _main()
|
|
|
|
if err != nil {
|
2022-07-07 22:12:22 +00:00
|
|
|
log.Fatal(err.Error())
|
2022-07-07 22:07:03 +00:00
|
|
|
}
|
|
|
|
}
|