From 54121fc8ffc532b5e8f1b283d917f572767e66ef Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Sun, 29 Jun 2025 18:21:22 +0300 Subject: [PATCH] Fancy pseudoterminal via TCP/Telnet --- internal/game/data/common.go | 3 + internal/game/data/event.go | 3 +- internal/game/data/output.go | 6 + internal/game/data/player.go | 17 + internal/game/game.go | 91 +++- internal/game/logic/command/command.go | 4 +- internal/game/logic/command/commands.go | 25 +- internal/game/logic/event/connection.go | 9 +- .../game/logic/event/{command.go => input.go} | 4 +- internal/game/logic/systems.go | 10 +- internal/game/logic/world/events.go | 22 +- internal/game/logic/world/output.go | 79 +++- internal/game/logic/world/player.go | 11 + internal/server/connection.go | 154 +++---- internal/server/server.go | 23 +- internal/term/terminal.go | 407 ++++++++++++++++++ 16 files changed, 741 insertions(+), 127 deletions(-) rename internal/game/logic/event/{command.go => input.go} (96%) create mode 100644 internal/term/terminal.go diff --git a/internal/game/data/common.go b/internal/game/data/common.go index 04a064b..4ee92fc 100644 --- a/internal/game/data/common.go +++ b/internal/game/data/common.go @@ -14,9 +14,12 @@ const ( TypeIsRoom TypeIsPlayer TypePlayer + TypeInput + TypeInputBuffer TypeCommandString TypeEntity TypeEvent + TypeIsOutput TypeConnectionId TypeContents TypeCloseConnection diff --git a/internal/game/data/event.go b/internal/game/data/event.go index fb70c90..f682ef3 100644 --- a/internal/game/data/event.go +++ b/internal/game/data/event.go @@ -9,7 +9,8 @@ type EventType string const ( EventPlayerConnect EventType = "PlayerConnect" EventPlayerDisconnect EventType = "PlayerDisconnect" - EventPlayerCommand EventType = "PlayerCommand" + EventPlayerInput EventType = "PlayerInput" + EventSubmitInput EventType = "PlayerCommand" EventParseCommand EventType = "ParseCommand" EventCommandExecuted EventType = "CommandExecuted" EventPlayerSpeak EventType = "PlayerSpeak" diff --git a/internal/game/data/output.go b/internal/game/data/output.go index e8f28f6..0337302 100644 --- a/internal/game/data/output.go +++ b/internal/game/data/output.go @@ -4,6 +4,12 @@ import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" ) +type IsOutputComponent struct{} + +func (io IsOutputComponent) Type() ecs.ComponentType { + return TypeIsOutput +} + type ContentsComponent struct { Contents []byte } diff --git a/internal/game/data/player.go b/internal/game/data/player.go index 7823e28..bfe90f5 100644 --- a/internal/game/data/player.go +++ b/internal/game/data/player.go @@ -33,3 +33,20 @@ type IsPlayerComponent struct{} func (c IsPlayerComponent) Type() ecs.ComponentType { return TypeIsPlayer } + +type InputComponent struct { + Input rune +} + +func (i InputComponent) Type() ecs.ComponentType { + return TypeInput +} + +type InputBufferComponent struct { + HandlingEscapeCode bool + InputBuffer string +} + +func (ib InputBufferComponent) Type() ecs.ComponentType { + return TypeInputBuffer +} diff --git a/internal/game/game.go b/internal/game/game.go index 84b7831..8121fef 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -16,6 +16,20 @@ import ( const TickRate = 50 * time.Millisecond +type InputType = string + +const ( + Connect InputType = "Connect" + Disconnect = "Disconnect" + Command = "Command" +) + +type Input struct { + connId uuid.UUID + inputType InputType + command string +} + type Output struct { connId uuid.UUID contents []byte @@ -40,15 +54,22 @@ type Game struct { world *World + input chan Input output chan Output + + stop context.CancelFunc } func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *Game) { + ctx, cancel := context.WithCancel(ctx) + game = &Game{ wg: wg, ctx: ctx, - output: make(chan Output), + input: make(chan Input, 1000), + output: make(chan Output, 1000), world: CreateGameWorld(), + stop: cancel, } ecs.RegisterSystems(game.world.World, logic.CreateSystems()...) @@ -61,6 +82,10 @@ func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *Game) { // ConsumeNextOutput will block if no output present func (game *Game) ConsumeNextOutput() *Output { + if game.shouldStop() { + return nil + } + select { case output := <-game.output: return &output @@ -69,16 +94,38 @@ func (game *Game) ConsumeNextOutput() *Output { } } -func (game *Game) ConnectPlayer(connectionId uuid.UUID) { - world.CreatePlayerConnectEvent(game.world.World, connectionId) +func (game *Game) Connect(connectionId uuid.UUID) { + if game.shouldStop() { + return + } + + game.input <- Input{ + inputType: Connect, + connId: connectionId, + } } -func (game *Game) DisconnectPlayer(connectionId uuid.UUID) { - world.CreatePlayerDisconnectEvent(game.world.World, connectionId) +func (game *Game) Disconnect(connectionId uuid.UUID) { + if game.shouldStop() { + return + } + + game.input <- Input{ + inputType: Disconnect, + connId: connectionId, + } } -func (game *Game) SendPlayerCommand(connectionId uuid.UUID, command string) { - world.CreatePlayerCommandEvent(game.world.World, connectionId, command) +func (game *Game) SendCommand(connectionId uuid.UUID, cmd string) { + if game.shouldStop() { + return + } + + game.input <- Input{ + inputType: Command, + connId: connectionId, + command: cmd, + } } func (game *Game) start() { @@ -108,7 +155,7 @@ func (game *Game) start() { } func (game *Game) consumeOutputs() { - entities := ecs.FindEntitiesWithComponents(game.world.World, data.TypeConnectionId, data.TypeContents) + entities := ecs.FindEntitiesWithComponents(game.world.World, data.TypeIsOutput, data.TypeConnectionId, data.TypeContents) for _, entity := range entities { output := Output{} @@ -134,6 +181,7 @@ func (game *Game) consumeOutputs() { func (game *Game) shutdown() { logging.Info("Stopping LastMUD...") close(game.output) + close(game.input) } func (game *Game) shouldStop() bool { @@ -145,11 +193,38 @@ func (game *Game) shouldStop() bool { } } +func (game *Game) nextInput() *Input { + select { + case input := <-game.input: + return &input + default: + return nil + } +} + func (game *Game) enqeueOutput(output Output) { game.output <- output } func (game *Game) tick(delta time.Duration) { + for { + input := game.nextInput() + + if input == nil { + break + } + + switch input.inputType { + case Connect: + world.CreatePlayerConnectEvent(game.world.World, input.connId) + case Disconnect: + world.CreatePlayerDisconnectEvent(game.world.World, input.connId) + case Command: + world.CreateSubmitInputEvent(game.world.World, input.connId, input.command) + } + } + game.world.Tick(delta) + game.consumeOutputs() } diff --git a/internal/game/logic/command/command.go b/internal/game/logic/command/command.go index b1a21d7..ce39b44 100644 --- a/internal/game/logic/command/command.go +++ b/internal/game/logic/command/command.go @@ -16,7 +16,7 @@ type commandError struct { func createCommandError(v ...any) *commandError { return &commandError{ - err: fmt.Sprint("Error handling command: ", v), + err: fmt.Sprint("Command error: ", v), } } @@ -50,7 +50,7 @@ func CreateHandler(command data.Command, handler Handler) ecs.SystemExecutor { connId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, player.Player) - world.CreateGameOutput(w, connId.ConnectionId, []byte(err.Error())) + world.CreateGameOutput(w, connId.ConnectionId, err.Error()) } ecs.SetComponent(w, c, data.CommandStateComponent{State: data.CommandStateExecuted}) diff --git a/internal/game/logic/command/commands.go b/internal/game/logic/command/commands.go index ea7cf74..24528e7 100644 --- a/internal/game/logic/command/commands.go +++ b/internal/game/logic/command/commands.go @@ -2,6 +2,7 @@ package command import ( "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" + "regexp" "time" "code.haedhutner.dev/mvv/LastMUD/internal/ecs" @@ -12,13 +13,13 @@ func HandleSay(w *ecs.World, _ time.Duration, player ecs.Entity, args data.ArgsM playerRoom, ok := ecs.GetComponent[data.InRoomComponent](w, player) if !ok { - return createCommandError("Player is not in any room!") + return createCommandError("You aren't in a room!") } playerName, ok := ecs.GetComponent[data.NameComponent](w, player) if !ok { - return createCommandError("Player has no name!") + return createCommandError("You have no name!") } allPlayersInRoom := ecs.QueryEntitiesWithComponent(w, func(comp data.InRoomComponent) bool { @@ -36,9 +37,7 @@ func HandleSay(w *ecs.World, _ time.Duration, player ecs.Entity, args data.ArgsM } for p := range allPlayersInRoom { - connId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, p) - - world.CreateGameOutput(w, connId.ConnectionId, []byte(playerName.Name+": "+message)) + world.SendMessageToPlayer(w, p, playerName.Name+": "+message) } return @@ -52,19 +51,25 @@ func HandleQuit(w *ecs.World, _ time.Duration, player ecs.Entity, _ data.ArgsMap return } -func HandleRegister(world *ecs.World, delta time.Duration, player ecs.Entity, args data.ArgsMap) (err error) { +var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,24}$`) + +func HandleRegister(w *ecs.World, delta time.Duration, player ecs.Entity, args data.ArgsMap) (err error) { accountName, err := arg[string](args, data.ArgAccountName) if err != nil { return err } - accountPassword, err := arg[string](args, data.ArgAccountPassword) - - if err != nil { - return err + if !usernameRegex.MatchString(accountName) { + world.SendMessageToPlayer(w, player, "Registration: Username must only contain letters, numbers, dashes (-) and underscores (_), and be at most 24 characters in length.") } + //accountPassword, err := arg[string](args, data.ArgAccountPassword) + // + //if err != nil { + // return err + //} + // TODO: validate username and password, encrypt password, etc. return diff --git a/internal/game/logic/event/connection.go b/internal/game/logic/event/connection.go index 478a93b..61608d1 100644 --- a/internal/game/logic/event/connection.go +++ b/internal/game/logic/event/connection.go @@ -4,12 +4,9 @@ import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" "code.haedhutner.dev/mvv/LastMUD/internal/game/data" "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" - "code.haedhutner.dev/mvv/LastMUD/internal/logging" ) func HandlePlayerConnect(w *ecs.World, event ecs.Entity) (err error) { - logging.Info("Player connect") - connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, event) if !ok { @@ -17,15 +14,13 @@ func HandlePlayerConnect(w *ecs.World, event ecs.Entity) (err error) { } world.CreateJoiningPlayer(w, connectionId.ConnectionId) - world.CreateGameOutput(w, connectionId.ConnectionId, []byte("Welcome to LastMUD!")) - world.CreateGameOutput(w, connectionId.ConnectionId, []byte("Before interacting with the game, you must either login or create a new account. Do so using the 'register' and 'login' command(s).")) + world.CreateGameOutput(w, connectionId.ConnectionId, "Welcome to LastMUD!") + world.CreateGameOutput(w, connectionId.ConnectionId, "Before interacting with the game, you must either login or create a new account. Do so using the 'register' and 'login' command(s).") return } func HandlePlayerDisconnect(w *ecs.World, event ecs.Entity) (err error) { - logging.Info("Player disconnect") - connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, event) if !ok { diff --git a/internal/game/logic/event/command.go b/internal/game/logic/event/input.go similarity index 96% rename from internal/game/logic/event/command.go rename to internal/game/logic/event/input.go index 792d4a1..53ede5c 100644 --- a/internal/game/logic/event/command.go +++ b/internal/game/logic/event/input.go @@ -23,7 +23,7 @@ func (e *commandParseError) Error() string { return e.err } -func HandlePlayerCommand(w *ecs.World, event ecs.Entity) (err error) { +func HandleSubmitInput(w *ecs.World, event ecs.Entity) (err error) { commandString, ok := ecs.GetComponent[data.CommandStringComponent](w, event) if !ok { @@ -157,7 +157,7 @@ func HandleParseCommand(w *ecs.World, event ecs.Entity) (err error) { connectionId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, player.Player) if !foundMatch { - world.CreateGameOutput(w, connectionId.ConnectionId, []byte("Unknown command")) + world.CreateGameOutput(w, connectionId.ConnectionId, "Unknown command") ecs.DeleteEntity(w, cmdEnt.Entity) } diff --git a/internal/game/logic/systems.go b/internal/game/logic/systems.go index 9342ac2..9d93e63 100644 --- a/internal/game/logic/systems.go +++ b/internal/game/logic/systems.go @@ -16,13 +16,13 @@ func CreateSystems() []*ecs.System { return []*ecs.System{ // Event Handlers ecs.CreateSystem("PlayerConnectEventHandler", EventOffset+0, event.CreateHandler(data.EventPlayerConnect, event.HandlePlayerConnect)), - ecs.CreateSystem("PlayerDisconnectEventHandler", EventOffset+1, event.CreateHandler(data.EventPlayerDisconnect, event.HandlePlayerDisconnect)), - ecs.CreateSystem("PlayerCommandEventHandler", EventOffset+2, event.CreateHandler(data.EventPlayerCommand, event.HandlePlayerCommand)), - ecs.CreateSystem("ParseCommandEventHandler", EventOffset+4, event.CreateHandler(data.EventParseCommand, event.HandleParseCommand)), + ecs.CreateSystem("PlayerDisconnectEventHandler", EventOffset+10, event.CreateHandler(data.EventPlayerDisconnect, event.HandlePlayerDisconnect)), + ecs.CreateSystem("PlayerSubmitInputEventHandler", EventOffset+30, event.CreateHandler(data.EventSubmitInput, event.HandleSubmitInput)), + ecs.CreateSystem("ParseCommandEventHandler", EventOffset+40, event.CreateHandler(data.EventParseCommand, event.HandleParseCommand)), // Command Handlers ecs.CreateSystem("SayCommandHandler", CommandOffset+0, command.CreateHandler(data.CommandSay, command.HandleSay)), - ecs.CreateSystem("QuitCommandHandler", CommandOffset+1, command.CreateHandler(data.CommandQuit, command.HandleQuit)), - ecs.CreateSystem("RegisterCommandHandler", CommandOffset+2, command.CreateHandler(data.CommandRegister, command.HandleRegister)), + ecs.CreateSystem("QuitCommandHandler", CommandOffset+10, command.CreateHandler(data.CommandQuit, command.HandleQuit)), + // ecs.CreateSystem("RegisterCommandHandler", CommandOffset+20, command.CreateHandler(data.CommandRegister, command.HandleRegister)), } } diff --git a/internal/game/logic/world/events.go b/internal/game/logic/world/events.go index 43f2c29..a80d13c 100644 --- a/internal/game/logic/world/events.go +++ b/internal/game/logic/world/events.go @@ -3,6 +3,7 @@ package world import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "code.haedhutner.dev/mvv/LastMUD/internal/logging" "github.com/google/uuid" ) @@ -20,10 +21,27 @@ func CreatePlayerDisconnectEvent(world *ecs.World, connectionId uuid.UUID) { ecs.SetComponent(world, event, data.ConnectionIdComponent{ConnectionId: connectionId}) } -func CreatePlayerCommandEvent(world *ecs.World, connectionId uuid.UUID, command string) { +func CreatePlayerInputEvent(world *ecs.World, connectionId uuid.UUID, input rune) { + player := ecs.QueryFirstEntityWithComponent[data.ConnectionIdComponent](world, func(comp data.ConnectionIdComponent) bool { + return comp.ConnectionId == connectionId + }) + + if player == ecs.NilEntity() { + logging.Error("Trying to process input event for connection '", connectionId.String(), "' which does not have a corresponding player") + return + } + event := ecs.NewEntity() - ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventPlayerCommand}) + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventPlayerInput}) + ecs.SetComponent(world, event, data.PlayerComponent{Player: player}) + ecs.SetComponent(world, event, data.InputComponent{Input: input}) +} + +func CreateSubmitInputEvent(world *ecs.World, connectionId uuid.UUID, command string) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventSubmitInput}) ecs.SetComponent(world, event, data.ConnectionIdComponent{ConnectionId: connectionId}) ecs.SetComponent(world, event, data.CommandStringComponent{Command: command}) } diff --git a/internal/game/logic/world/output.go b/internal/game/logic/world/output.go index 13af4bb..9a54b19 100644 --- a/internal/game/logic/world/output.go +++ b/internal/game/logic/world/output.go @@ -6,11 +6,85 @@ import ( "github.com/google/uuid" ) -func CreateGameOutput(w *ecs.World, connectionId uuid.UUID, contents []byte) ecs.Entity { +// Rules for output: +// - All server output is prepended with "< " +// - Server output is max 80 characters per line ( including th above prefix ) +// - If longer than 80 characters, break up into multiple lines +// - Following lines of same output are not prepended with "< ", but have an offset to account for it +// - Bottom-most line of telnet must always start with "> ", and if input currently exists there, must be preserved in case of server output + +//const ( +// maxLineLength = 80 +// prefix = "< " +// continuation = " " // 2-space offset to align with prefix +//) + +//func formatServerOutput(text string) []string { +// // Strip newline characters to avoid confusion +// text = strings.ReplaceAll(text, "\n", " ") +// words := strings.Fields(text) +// +// var lines []string +// line := prefix +// +// for _, word := range words { +// // Check if word fits on current line +// if len(line)+len(word)+1 > maxLineLength { +// lines = append(lines, line) +// // Begin new line with continuation indent +// line = continuation + word +// } else { +// if len(line) > len(continuation) { +// line += " " + word +// } else { +// line += word +// } +// } +// } +// +// if len(line) > 0 { +// lines = append(lines, line) +// } +// +// return lines +//} +// +//func formatServerOutputPreservingInput(output string) []byte { +// lines := formatServerOutput(output) +// +// saveCursor := "\x1b7" +// restoreCursor := "\x1b8" +// moveCursorUp := func(n int) string { +// return fmt.Sprintf("\x1b[%dA", n) +// } +// eraseLine := "\x1b[2K" +// carriageReturn := "\r" +// +// // Build the full output string +// var builder strings.Builder +// +// builder.WriteString(saveCursor) // Save cursor (input line) +// builder.WriteString(moveCursorUp(1)) // Move up to output line +// +// for _, line := range lines { +// builder.WriteString(eraseLine) // Clear line +// builder.WriteString(carriageReturn) // Reset to beginning +// builder.WriteString(line) // Write output line +// builder.WriteString("\n") // Move to next line +// } +// +// builder.WriteString(restoreCursor) // Return to input line +// +// // Send the whole update in one write +// return []byte(builder.String()) +//} + +func CreateGameOutput(w *ecs.World, connectionId uuid.UUID, contents string) ecs.Entity { gameOutput := ecs.NewEntity() + ecs.SetComponent(w, gameOutput, data.IsOutputComponent{}) ecs.SetComponent(w, gameOutput, data.ConnectionIdComponent{ConnectionId: connectionId}) - ecs.SetComponent(w, gameOutput, data.ContentsComponent{Contents: contents}) + ecs.SetComponent(w, gameOutput, data.ContentsComponent{Contents: []byte(contents)}) return gameOutput } @@ -18,6 +92,7 @@ func CreateGameOutput(w *ecs.World, connectionId uuid.UUID, contents []byte) ecs func CreateClosingGameOutput(w *ecs.World, connectionId uuid.UUID, contents []byte) ecs.Entity { gameOutput := ecs.NewEntity() + ecs.SetComponent(w, gameOutput, data.IsOutputComponent{}) ecs.SetComponent(w, gameOutput, data.ConnectionIdComponent{ConnectionId: connectionId}) ecs.SetComponent(w, gameOutput, data.ContentsComponent{Contents: contents}) ecs.SetComponent(w, gameOutput, data.CloseConnectionComponent{}) diff --git a/internal/game/logic/world/player.go b/internal/game/logic/world/player.go index b43c041..b095fd2 100644 --- a/internal/game/logic/world/player.go +++ b/internal/game/logic/world/player.go @@ -13,6 +13,17 @@ func CreateJoiningPlayer(world *ecs.World, connectionId uuid.UUID) (entity ecs.E ecs.SetComponent(world, entity, data.PlayerStateComponent{State: data.PlayerStateJoining}) ecs.SetComponent(world, entity, data.NameComponent{Name: connectionId.String()}) ecs.SetComponent(world, entity, data.IsPlayerComponent{}) + ecs.SetComponent(world, entity, data.InputBufferComponent{InputBuffer: ""}) return } + +func SendMessageToPlayer(world *ecs.World, player ecs.Entity, message string) { + connId, ok := ecs.GetComponent[data.ConnectionIdComponent](world, player) + + if !ok { + return + } + + CreateGameOutput(world, connId.ConnectionId, message) +} diff --git a/internal/server/connection.go b/internal/server/connection.go index a449e45..d633993 100644 --- a/internal/server/connection.go +++ b/internal/server/connection.go @@ -2,56 +2,66 @@ package server import ( "bufio" + "code.haedhutner.dev/mvv/LastMUD/internal/logging" + "code.haedhutner.dev/mvv/LastMUD/internal/term" "context" + "github.com/google/uuid" "net" "sync" "time" - - "code.haedhutner.dev/mvv/LastMUD/internal/logging" - "github.com/google/uuid" ) -const MaxLastSeenTime = 90 * time.Second - const CheckAlivePeriod = 50 * time.Millisecond -const DeleteBeforeAndMoveToStartOfLine = "\033[1K\r" - type Connection struct { - ctx context.Context - wg *sync.WaitGroup - + ctx context.Context + wg *sync.WaitGroup server *Server identity uuid.UUID + term *term.VirtualTerm conn *net.TCPConn lastSeen time.Time - closeChan chan struct{} + stop context.CancelFunc } -func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup) (c *Connection) { - logging.Info("Connect: ", conn.RemoteAddr()) +func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup) (c *Connection, err error) { + ctx, cancel := context.WithCancel(ctx) - conn.SetKeepAlive(true) - conn.SetKeepAlivePeriod(1 * time.Second) + t, err := term.CreateVirtualTerm( + ctx, + wg, + func(t time.Time) { + _ = conn.SetReadDeadline(t) + }, + bufio.NewReader(conn), + bufio.NewWriter(conn), + ) - c = &Connection{ - ctx: ctx, - wg: wg, - server: server, - identity: uuid.New(), - conn: conn, - lastSeen: time.Now(), - closeChan: make(chan struct{}, 1), + if err != nil { + cancel() + return nil, err } - c.wg.Add(2) - go c.listen() - go c.checkAlive() + c = &Connection{ + ctx: ctx, + wg: wg, + server: server, + identity: uuid.New(), + term: t, + conn: conn, + lastSeen: time.Now(), + stop: cancel, + } - server.game().ConnectPlayer(c.Id()) + logging.Info("Connection from ", c.conn.RemoteAddr(), ": Assigned id ", c.Id().String()) + + wg.Add(1) + go c.checkAliveAndConsumeCommands() + + server.game().Connect(c.Id()) return } @@ -61,56 +71,16 @@ func (c *Connection) Id() uuid.UUID { } func (c *Connection) Write(output []byte) (err error) { - output = append([]byte(DeleteBeforeAndMoveToStartOfLine+"< "), output...) - output = append(output, []byte("\n> ")...) - _, err = c.conn.Write(output) + if c.shouldClose() { + return nil + } + + err = c.term.Write(output) return } -func (c *Connection) listen() { - defer c.wg.Done() - - logging.Info("Listening on connection ", c.conn.RemoteAddr()) - - for { - c.conn.SetReadDeadline(time.Time{}) - - message, err := bufio.NewReader(c.conn).ReadString('\n') - - if err != nil { - logging.Warn(err) - break - } - - c.server.game().SendPlayerCommand(c.Id(), message) - - c.lastSeen = time.Now() - } -} - -func (c *Connection) checkAlive() { - defer c.wg.Done() - defer c.closeConnection() - - for { - if c.shouldClose() { - c.Write([]byte("Server shutting down, bye bye!\r\n")) - break - } - - if time.Since(c.lastSeen) > MaxLastSeenTime { - c.Write([]byte("You have been away for too long, bye bye!\r\n")) - break - } - - _, err := c.conn.Write([]byte{0x00}) - - if err != nil { - break - } - - time.Sleep(CheckAlivePeriod) - } +func (c *Connection) Close() { + c.stop() } func (c *Connection) shouldClose() bool { @@ -120,23 +90,39 @@ func (c *Connection) shouldClose() bool { default: } - select { - case <-c.closeChan: - return true - default: - } - return false } -func (c *Connection) CommandClose() { - c.closeChan <- struct{}{} +func (c *Connection) checkAliveAndConsumeCommands() { + defer c.wg.Done() + defer c.closeConnection() + + for { + if c.shouldClose() { + break + } + + _, err := c.conn.Write([]byte{0x00}) + + if err != nil { + break + } + + cmd := c.term.NextCommand() + + if cmd != "" { + c.server.game().SendCommand(c.Id(), cmd) + } + + time.Sleep(CheckAlivePeriod) + } } func (c *Connection) closeConnection() { + c.term.Close() c.conn.Close() - c.server.game().DisconnectPlayer(c.Id()) + c.server.game().Disconnect(c.Id()) - logging.Info("Disconnected: ", c.conn.RemoteAddr()) + logging.Info("Disconnect ", c.conn.RemoteAddr(), " with id ", c.Id().String()) } diff --git a/internal/server/server.go b/internal/server/server.go index af7d728..2b79ec4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,9 +20,12 @@ type Server struct { connections map[uuid.UUID]*Connection lastmudgame *game.Game + + stop context.CancelFunc } func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Server, err error) { + ctx, cancel := context.WithCancel(ctx) logging.Info(" _ _ __ __ _ _ ____") logging.Info("| | __ _ ___| |_| \\/ | | | | _ \\") @@ -35,6 +38,7 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se if err != nil { logging.Error(err) + cancel() return nil, err } @@ -42,6 +46,7 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se if err != nil { logging.Error(err) + cancel() return nil, err } @@ -52,6 +57,7 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se wg: wg, listener: ln, connections: map[uuid.UUID]*Connection{}, + stop: cancel, } srv.lastmudgame = game.CreateGame(ctx, srv.wg) @@ -91,9 +97,14 @@ func (srv *Server) listen() { continue } - c := CreateConnection(srv, tcpConn, srv.ctx, srv.wg) + c, err := CreateConnection(srv, tcpConn, srv.ctx, srv.wg) - srv.connections[c.Id()] = c + if err != nil { + logging.Error("Unable to create connection: ", err) + _ = tcpConn.Close() + } else { + srv.connections[c.Id()] = c + } } } @@ -114,11 +125,15 @@ func (srv *Server) consumeGameOutput() { conn, ok := srv.connections[output.Id()] if ok && output.Contents() != nil { - conn.Write(output.Contents()) + err := conn.Write(output.Contents()) + + if err != nil { + logging.Error("Error writing to connection ", output.Id(), ": ", err) + } } if output.ShouldCloseConnection() { - conn.CommandClose() + conn.Close() delete(srv.connections, output.Id()) } } diff --git a/internal/term/terminal.go b/internal/term/terminal.go new file mode 100644 index 0000000..e6e83d0 --- /dev/null +++ b/internal/term/terminal.go @@ -0,0 +1,407 @@ +package term + +import ( + "bufio" + "bytes" + "code.haedhutner.dev/mvv/LastMUD/internal/logging" + "context" + "encoding/binary" + "fmt" + "slices" + "sync" + "time" +) + +type termError struct { + err string +} + +func createTermError(v ...any) *termError { + return &termError{ + err: fmt.Sprint(v...), + } +} + +func (te *termError) Error() string { + return te.err +} + +// NAWS ( RFC 1073 ): +// - Server -> Client: IAC DO NAWS +// - Client -> Server: IAC WILL NAWS +// - Client -> Server: IAC SB NAWS 0 0 IAC SE +// +// ECHO ( RFC 857 ) +// - Server -> Client: IAC WILL ECHO +// - Client -> Server: IAC DO ECHO +// +// SUPPRESSGOAHEAD ( RFC 858 ) +// - Server -> Client: IAC WILL SUPPRESSGOAHEAD +// - Client -> Server: IAC DO SUPPRESSGOAHEAD +// +// LINEMODE ( RFC 1184 ) +// - Server -> Client: IAC DONT LINEMODE +// - Client -> Server: IAC WONT LINEMODE +const ( + IAC byte = 255 + DO byte = 253 + DONT byte = 254 + WILL byte = 251 + WONT byte = 252 + SB byte = 250 + SE byte = 240 + ECHO byte = 1 + SUPPRESSGOAHEAD byte = 3 + NAWS byte = 31 + LINEMODE byte = 34 +) + +func telnetByteToString(telnetByte byte) string { + switch telnetByte { + case IAC: + return "IAC" + case DO: + return "DO" + case DONT: + return "DONT" + case WILL: + return "WILL" + case WONT: + return "WONT" + case SB: + return "SB" + case SE: + return "SE" + case ECHO: + return "ECHO" + case SUPPRESSGOAHEAD: + return "SUPPRESS-GO-AHEAD" + case NAWS: + return "NAWS" + case LINEMODE: + return "LINEMODE" + default: + return fmt.Sprintf("%02X", telnetByte) + } +} + +const ( + ClearScreen = "\x1b[2J" + ClearLine = "\x1b[K" + MoveCursorStartOfLine = "\x1b[G" + SaveCursorPosition = "\x1b[s" + RestoreCursorPosition = "\x1b[u" + ScrollUp = "\x1b[1S" + MoveCursorUpOne = "\x1b[1A" + MoveCursorRightOne = "\x1b[1C" + MoveCursorLeftOne = "\x1b[1D" +) + +type VirtualTerm struct { + ctx context.Context + wg *sync.WaitGroup + + buffer *bytes.Buffer + cX, xY int + + width, height int + + timeout func(t time.Time) + + reader *bufio.Reader + writer *bufio.Writer + + writes chan []byte + submitted chan string + + stop context.CancelFunc +} + +func CreateVirtualTerm(ctx context.Context, wg *sync.WaitGroup, timeout func(t time.Time), reader *bufio.Reader, writer *bufio.Writer) (term *VirtualTerm, err error) { + ctx, cancel := context.WithCancel(ctx) + term = &VirtualTerm{ + ctx: ctx, + stop: cancel, + wg: wg, + buffer: bytes.NewBuffer([]byte{}), + cX: 0, + xY: 0, + width: 80, // Default of 80 + height: 24, // Default of 24 + timeout: timeout, + reader: reader, + writer: writer, + submitted: make(chan string, 1), + writes: make(chan []byte, 100), + } + + err = term.sendWillSuppressGA() + err = term.sendWillEcho() + err = term.sendDisableLinemode() + err = term.sendNAWSNegotiationRequest() + + if err != nil { + logging.Error(err) + } + + wg.Add(2) + go term.listen() + go term.send() + + return +} + +func (term *VirtualTerm) Close() { + term.stop() +} + +func (term *VirtualTerm) NextCommand() (cmd string) { + if term.shouldStop() { + return + } + + select { + case cmd = <-term.submitted: + return cmd + default: + return + } +} + +func (term *VirtualTerm) Write(bytes []byte) (err error) { + if term.shouldStop() { + return + } + + term.writes <- bytes + return +} + +func (term *VirtualTerm) readByte() (b byte, err error) { + term.timeout(time.Now().Add(1000 * time.Millisecond)) + b, err = term.reader.ReadByte() + return +} + +func (term *VirtualTerm) listen() { + defer term.wg.Done() + + for { + if term.shouldStop() { + break + } + + b, err := term.readByte() + + if err != nil { + continue + } + + switch b { + case IAC: + err = term.handleTelnetCommand() + + if err != nil { + term.Close() + break + } + + continue + case '\b', 127: + if term.buffer.Len() <= 0 { + continue + } + + term.buffer = bytes.NewBuffer(term.buffer.Bytes()[0 : len(term.buffer.Bytes())-1]) + term.writer.Write([]byte("\x1b[D")) + term.writer.Write([]byte("\x1b[P")) + case '\r', '\n': + if !term.shouldStop() { + term.submitted <- term.buffer.String() + } + term.buffer = bytes.NewBuffer([]byte{}) + case '\t', '\a', '\f', '\v': + continue + default: + } + + if isInputCharacter(b) { + term.buffer.WriteByte(b) + } + + term.writer.Write([]byte(ClearLine)) + term.writer.Write([]byte(MoveCursorStartOfLine)) + term.writer.Write([]byte("> ")) + term.writer.Write(term.buffer.Bytes()) + } +} + +func isInputCharacter(b byte) bool { + return b >= 0x20 && b <= 0x7E +} + +func (term *VirtualTerm) send() { + defer term.wg.Done() + + _ = term.sendClear() + + for { + if term.shouldStop() { + break + } + + select { + case write := <-term.writes: + for _, w := range term.formatOutput(write) { + term.writer.Write([]byte(MoveCursorStartOfLine)) + term.writer.Write([]byte(ClearLine)) + term.writer.Write([]byte(w)) + term.writer.Write([]byte("\r\n")) + } + + term.writer.Write([]byte("> ")) + term.writer.Write(term.buffer.Bytes()) + default: + } + + err := term.writer.Flush() + + if err != nil { + logging.Error(err) + } + + time.Sleep(10 * time.Millisecond) + } +} + +func (term *VirtualTerm) sendNAWSNegotiationRequest() (err error) { + _, err = term.writer.Write([]byte{IAC, DO, NAWS}) + return +} + +func (term *VirtualTerm) sendWillEcho() (err error) { + _, err = term.writer.Write([]byte{IAC, WILL, ECHO}) + return +} + +func (term *VirtualTerm) sendWillSuppressGA() (err error) { + _, err = term.writer.Write([]byte{IAC, WILL, SUPPRESSGOAHEAD}) + return +} + +func (term *VirtualTerm) sendDisableLinemode() (err error) { + _, err = term.writer.Write([]byte{IAC, DONT, LINEMODE}) + return +} + +func (term *VirtualTerm) sendClear() (err error) { + _, err = term.writer.Write([]byte(ClearScreen)) + return +} + +func (term *VirtualTerm) handleTelnetCommand() (err error) { + buf := make([]byte, 255) + buf[0] = IAC + + next, err := term.readByte() + + if err != nil { + return err + } + + lastIndex := 1 + + switch next { + case IAC: + // Double IAC, meant to be interpreted as literal + term.buffer.WriteByte(255) + case DO, DONT, WILL, WONT: + // Negotiation + lastIndex++ + buf[lastIndex] = next + final, err := term.readByte() + + if err != nil { + return err + } + + lastIndex++ + buf[lastIndex] = final + default: + // Send begin, send end + for { + next, err := term.readByte() + + if err != nil { + return err + } + + buf[lastIndex] = next + + if next == SE { + break + } + + lastIndex++ + } + } + + strRep := make([]string, lastIndex+1) + + for i := 0; i < lastIndex+1; i++ { + strRep[i] = telnetByteToString(buf[i]) + } + + if slices.Equal([]byte{IAC, WONT, NAWS}, buf[:3]) { + // Client does not agree to NAWS, cannot proceed + return createTermError("NAWS negotiation failed") + } else if slices.Equal([]byte{IAC, DONT, SUPPRESSGOAHEAD}, buf[:3]) { + // Client does not agree to suppress go-ahead + return createTermError("suppress-go-ahead negotiation failed") + } else if slices.Equal([]byte{IAC, DONT, ECHO}, buf[:3]) { + // Client does not agree to not echo + return createTermError("No echo negotiation failed") + } else if slices.Equal([]byte{IAC, WILL, LINEMODE}, buf[:3]) { + return createTermError("Client wants to use linemode") + } else if slices.Equal([]byte{IAC, SB, NAWS}, buf[:3]) { + logging.Info("Received NAWS Response") + // Client sending NAWS data + term.width = int(binary.BigEndian.Uint16(buf[3:5])) + term.height = int(binary.BigEndian.Uint16(buf[5:7])) + } + + return +} + +func (term *VirtualTerm) shouldStop() bool { + select { + case <-term.ctx.Done(): + return true + default: + } + + return false +} + +func (term *VirtualTerm) formatOutput(output []byte) []string { + return []string{string(output)} + + // TODO + //strText := string(output) + // + //strText = strings.ReplaceAll(strText, "\n", " ") + //words := strings.Fields(strText) + // + //var lines [][]string + // + //for _, word := range words { + // + // //if len(line)+len(word) > term.width { + // // lines = append(lines, line) + // //} else { + // // line += " " + word + // //} + //} + // + //return lines +}