Fancy pseudoterminal via TCP/Telnet
This commit is contained in:
parent
a8a222f89b
commit
54121fc8ff
16 changed files with 741 additions and 127 deletions
|
@ -14,9 +14,12 @@ const (
|
|||
TypeIsRoom
|
||||
TypeIsPlayer
|
||||
TypePlayer
|
||||
TypeInput
|
||||
TypeInputBuffer
|
||||
TypeCommandString
|
||||
TypeEntity
|
||||
TypeEvent
|
||||
TypeIsOutput
|
||||
TypeConnectionId
|
||||
TypeContents
|
||||
TypeCloseConnection
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
func (game *Game) DisconnectPlayer(connectionId uuid.UUID) {
|
||||
world.CreatePlayerDisconnectEvent(game.world.World, connectionId)
|
||||
game.input <- Input{
|
||||
inputType: Connect,
|
||||
connId: connectionId,
|
||||
}
|
||||
}
|
||||
|
||||
func (game *Game) SendPlayerCommand(connectionId uuid.UUID, command string) {
|
||||
world.CreatePlayerCommandEvent(game.world.World, connectionId, command)
|
||||
func (game *Game) Disconnect(connectionId uuid.UUID) {
|
||||
if game.shouldStop() {
|
||||
return
|
||||
}
|
||||
|
||||
game.input <- Input{
|
||||
inputType: Disconnect,
|
||||
connId: connectionId,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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{})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c = &Connection{
|
||||
ctx: ctx,
|
||||
wg: wg,
|
||||
server: server,
|
||||
identity: uuid.New(),
|
||||
term: t,
|
||||
conn: conn,
|
||||
lastSeen: time.Now(),
|
||||
closeChan: make(chan struct{}, 1),
|
||||
stop: cancel,
|
||||
}
|
||||
|
||||
c.wg.Add(2)
|
||||
go c.listen()
|
||||
go c.checkAlive()
|
||||
logging.Info("Connection from ", c.conn.RemoteAddr(), ": Assigned id ", c.Id().String())
|
||||
|
||||
server.game().ConnectPlayer(c.Id())
|
||||
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())
|
||||
}
|
||||
|
|
|
@ -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,11 +97,16 @@ func (srv *Server) listen() {
|
|||
continue
|
||||
}
|
||||
|
||||
c := CreateConnection(srv, tcpConn, srv.ctx, srv.wg)
|
||||
c, err := CreateConnection(srv, tcpConn, srv.ctx, srv.wg)
|
||||
|
||||
if err != nil {
|
||||
logging.Error("Unable to create connection: ", err)
|
||||
_ = tcpConn.Close()
|
||||
} else {
|
||||
srv.connections[c.Id()] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *Server) consumeGameOutput() {
|
||||
defer srv.wg.Done()
|
||||
|
@ -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
407
internal/term/terminal.go
Normal 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
|
||||
}
|
Loading…
Add table
Reference in a new issue