Fancy pseudoterminal via TCP/Telnet

This commit is contained in:
Miroslav Vasilev 2025-06-29 18:21:22 +03:00
parent a8a222f89b
commit 54121fc8ff
16 changed files with 741 additions and 127 deletions

View file

@ -14,9 +14,12 @@ const (
TypeIsRoom
TypeIsPlayer
TypePlayer
TypeInput
TypeInputBuffer
TypeCommandString
TypeEntity
TypeEvent
TypeIsOutput
TypeConnectionId
TypeContents
TypeCloseConnection

View file

@ -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"

View file

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

View file

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

View file

@ -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()
}

View file

@ -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})

View file

@ -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

View file

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

View file

@ -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)
}

View file

@ -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)),
}
}

View file

@ -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})
}

View file

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

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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())
}
}

407
internal/term/terminal.go Normal file
View file

@ -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 <width> 0 <height> 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
}