From 308c343068a4a502fa83a259220b8fef307df50a Mon Sep 17 00:00:00 2001 From: mvvasilev Date: Sat, 28 Jun 2025 11:24:06 +0300 Subject: [PATCH] Restructure some things, move things about --- .gitignore | 8 +- Dockerfile | 25 +++ bin/build.sh | 9 + bin/build_docker.sh | 9 + bin/run.sh | 9 + bin/test_coverage.sh | 12 + cmd/lastmudserver/main.go | 33 +-- go.mod | 2 - internal/ecs/ecs.go | 19 +- internal/game/data/account.go | 19 ++ internal/game/data/command.go | 31 +-- internal/game/data/common.go | 3 + internal/game/data/event.go | 37 ---- internal/game/data/output.go | 12 - internal/game/data/player.go | 19 -- internal/game/data/resources.go | 7 + internal/game/data/room.go | 15 -- internal/game/game.go | 63 +++--- internal/game/logic/command/command.go | 67 ++++++ .../game/logic/command/command_parsers.go | 84 +++++++ internal/game/logic/command/commands.go | 63 ++++++ internal/game/logic/event/command.go | 165 ++++++++++++++ .../{systems => logic/event}/connection.go | 20 +- .../game/{systems => logic/event}/event.go | 8 +- internal/game/logic/systems.go | 28 +++ internal/game/logic/world/account.go | 15 ++ internal/game/logic/world/command.go | 17 ++ internal/game/logic/world/events.go | 43 ++++ internal/game/logic/world/output.go | 26 +++ internal/game/logic/world/player.go | 18 ++ internal/game/logic/world/room.go | 21 ++ internal/game/systems/command.go | 208 ------------------ internal/game/systems/command_parsers.go | 48 ---- internal/game/systems/commands.go | 60 ----- internal/game/systems/systems.go | 24 -- internal/game/{data => }/world.go | 48 ++-- internal/server/connection.go | 28 ++- internal/server/server.go | 6 +- 38 files changed, 781 insertions(+), 548 deletions(-) create mode 100644 Dockerfile create mode 100755 bin/build.sh create mode 100755 bin/build_docker.sh create mode 100755 bin/run.sh create mode 100755 bin/test_coverage.sh create mode 100644 internal/game/data/account.go create mode 100644 internal/game/data/resources.go create mode 100644 internal/game/logic/command/command.go create mode 100644 internal/game/logic/command/command_parsers.go create mode 100644 internal/game/logic/command/commands.go create mode 100644 internal/game/logic/event/command.go rename internal/game/{systems => logic/event}/connection.go (59%) rename internal/game/{systems => logic/event}/event.go (84%) create mode 100644 internal/game/logic/systems.go create mode 100644 internal/game/logic/world/account.go create mode 100644 internal/game/logic/world/command.go create mode 100644 internal/game/logic/world/events.go create mode 100644 internal/game/logic/world/output.go create mode 100644 internal/game/logic/world/player.go create mode 100644 internal/game/logic/world/room.go delete mode 100644 internal/game/systems/command.go delete mode 100644 internal/game/systems/command_parsers.go delete mode 100644 internal/game/systems/commands.go delete mode 100644 internal/game/systems/systems.go rename internal/game/{data => }/world.go (72%) diff --git a/.gitignore b/.gitignore index 81158cb..a80763c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ -log/* -*.log \ No newline at end of file +log/ +target/ +coverage/ +.idea/ + +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5b10944 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1: Build the Go application +FROM golang:1.24 as builder + +WORKDIR /lastmudserver + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN ./bin/build.sh + +# Stage 2: Create a smaller image with the compiled binary +FROM debian:stable + +WORKDIR /lastmudserver + +COPY --from=builder /lastmudserver/target/lastmudserver . + +RUN chmod 777 lastmudserver + +EXPOSE 8000 + +CMD ["./lastmudserver"] \ No newline at end of file diff --git a/bin/build.sh b/bin/build.sh new file mode 100755 index 0000000..120377a --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +pushd $(dirname "$0")/.. # run from root dir + +go build -o target/lastmudserver cmd/lastmudserver/main.go + +popd \ No newline at end of file diff --git a/bin/build_docker.sh b/bin/build_docker.sh new file mode 100755 index 0000000..c149b9f --- /dev/null +++ b/bin/build_docker.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +pushd $(dirname "$0")/.. # run from root dir + +docker build -t lastmudserver . + +popd \ No newline at end of file diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 0000000..0967ba5 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +pushd $(dirname "$0")/.. # run from root dir + +go run cmd/lastmudserver/main.go + +popd \ No newline at end of file diff --git a/bin/test_coverage.sh b/bin/test_coverage.sh new file mode 100755 index 0000000..2f0e27e --- /dev/null +++ b/bin/test_coverage.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +pushd $(dirname "$0")/.. # run from root dir + +rm -rf ./coverage +mkdir ./coverage/ + +go test --cover -coverpkg=./internal... -covermode=count -coverprofile=./coverage/cover.out ./... + +go tool cover -html=./coverage/cover.out + +popd # switch back to dir we started from \ No newline at end of file diff --git a/cmd/lastmudserver/main.go b/cmd/lastmudserver/main.go index 6beb05a..bdb753f 100644 --- a/cmd/lastmudserver/main.go +++ b/cmd/lastmudserver/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "log" "os" "os/signal" @@ -13,10 +14,15 @@ import ( _ "net/http/pprof" "code.haedhutner.dev/mvv/LastMUD/internal/server" - "golang.org/x/term" ) +var enableDiagnostics bool = false + func main() { + flag.BoolVar(&enableDiagnostics, "d", false, "Enable pprof server ( port :6060 ). Disabled by default.") + + flag.Parse() + ctx, cancel := context.WithCancel(context.Background()) wg := sync.WaitGroup{} @@ -29,27 +35,19 @@ func main() { log.Fatal(err) } - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() + if enableDiagnostics { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + } processInput() } func processInput() { - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - - if err != nil { - panic(err) - } - - defer term.Restore(int(os.Stdin.Fd()), oldState) - sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - buf := make([]byte, 1) - for { // If interrupt received, stop select { @@ -58,13 +56,6 @@ func processInput() { default: } - // TODO: Proper TUI for the server - os.Stdin.Read(buf) - - if buf[0] == 'q' { - return - } - time.Sleep(50 * time.Millisecond) } } diff --git a/go.mod b/go.mod index c8a93a6..577f56e 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,11 @@ go 1.24.4 require ( github.com/google/uuid v1.6.0 - golang.org/x/term v0.32.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.10.0 // indirect - golang.org/x/sys v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/ecs/ecs.go b/internal/ecs/ecs.go index 707a0d3..ddce10d 100644 --- a/internal/ecs/ecs.go +++ b/internal/ecs/ecs.go @@ -175,7 +175,7 @@ func RemoveResource(world *World, r Resource) { delete(world.resources, r) } -func registerComponent[T Component](world *World, compType ComponentType) { +func registerComponent(world *World, compType ComponentType) { if _, ok := world.componentsByType[compType]; ok { return } @@ -184,7 +184,7 @@ func registerComponent[T Component](world *World, compType ComponentType) { } func SetComponent[T Component](world *World, entity Entity, component T) { - registerComponent[T](world, component.Type()) + registerComponent(world, component.Type()) compStorage := world.componentsByType[component.Type()] @@ -197,7 +197,7 @@ func GetComponent[T Component](world *World, entity Entity) (component T, exists val, exists := storage.Get(entity) casted, castSuccess := val.(T) - return casted, (exists && castSuccess) + return casted, exists && castSuccess } func DeleteComponent[T Component](world *World, entity Entity) { @@ -209,9 +209,10 @@ func DeleteComponent[T Component](world *World, entity Entity) { func GetComponentStorage[T Component](world *World) (compStorage *ComponentStorage) { var zero T + // This is ok because the `Type` function is expected to return a hard-coded value and not depend on component state compType := zero.Type() - registerComponent[T](world, compType) + registerComponent(world, compType) return world.componentsByType[compType] } @@ -292,7 +293,11 @@ func RegisterSystem(world *World, s *System) { } func RegisterSystems(world *World, systems ...*System) { - for _, s := range systems { - RegisterSystem(world, s) - } + world.systems = append(world.systems, systems...) + slices.SortFunc( + world.systems, + func(a, b *System) int { + return a.priority - b.priority + }, + ) } diff --git a/internal/game/data/account.go b/internal/game/data/account.go new file mode 100644 index 0000000..744d80b --- /dev/null +++ b/internal/game/data/account.go @@ -0,0 +1,19 @@ +package data + +import "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + +type AccountComponent struct { + Account ecs.Entity +} + +func (ac AccountComponent) Type() ecs.ComponentType { + return TypeAccount +} + +type PasswordComponent struct { + EncryptedPassword string +} + +func (pc PasswordComponent) Type() ecs.ComponentType { + return TypePassword +} diff --git a/internal/game/data/command.go b/internal/game/data/command.go index 8f75cd4..a30ecb9 100644 --- a/internal/game/data/command.go +++ b/internal/game/data/command.go @@ -61,12 +61,22 @@ func (tc TokensComponent) Type() ecs.ComponentType { return TypeCommandTokens } +type ArgName = string + +const ( + ArgMessageContent ArgName = "messageContent" + ArgAccountName = "accountName" + ArgAccountPassword = "accountPassword" +) + type Arg struct { Value any } +type ArgsMap = map[ArgName]Arg + type ArgsComponent struct { - Args map[string]Arg + Args ArgsMap } func (ac ArgsComponent) Type() ecs.ComponentType { @@ -76,8 +86,12 @@ func (ac ArgsComponent) Type() ecs.ComponentType { type Command string const ( - CommandSay Command = "say" - CommandQuit = "quit" + CommandSay Command = "say" + CommandQuit = "quit" + CommandHelp = "help" + CommandSetName = "setname" + CommandLogin = "login" + CommandRegister = "register" ) type CommandComponent struct { @@ -87,14 +101,3 @@ type CommandComponent struct { func (cc CommandComponent) Type() ecs.ComponentType { return TypeCommand } - -func CreateTokenizedCommand(world *ecs.World, player ecs.Entity, commandString string, tokens []Token) ecs.Entity { - command := ecs.NewEntity() - - ecs.SetComponent(world, command, PlayerComponent{Player: player}) - ecs.SetComponent(world, command, CommandStringComponent{Command: commandString}) - ecs.SetComponent(world, command, TokensComponent{Tokens: tokens}) - ecs.SetComponent(world, command, CommandStateComponent{State: CommandStateTokenized}) - - return command -} diff --git a/internal/game/data/common.go b/internal/game/data/common.go index 0786709..04a064b 100644 --- a/internal/game/data/common.go +++ b/internal/game/data/common.go @@ -25,6 +25,9 @@ const ( TypeCommandState TypeCommandArgs TypeCommand + + TypeAccount + TypePassword ) type Direction byte diff --git a/internal/game/data/event.go b/internal/game/data/event.go index 6f246a0..fb70c90 100644 --- a/internal/game/data/event.go +++ b/internal/game/data/event.go @@ -2,7 +2,6 @@ package data import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "github.com/google/uuid" ) type EventType string @@ -23,39 +22,3 @@ type EventComponent struct { func (is EventComponent) Type() ecs.ComponentType { return TypeEvent } - -func CreatePlayerConnectEvent(world *ecs.World, connectionId uuid.UUID) { - event := ecs.NewEntity() - - ecs.SetComponent(world, event, EventComponent{EventType: EventPlayerConnect}) - ecs.SetComponent(world, event, ConnectionIdComponent{ConnectionId: connectionId}) -} - -func CreatePlayerDisconnectEvent(world *ecs.World, connectionId uuid.UUID) { - event := ecs.NewEntity() - - ecs.SetComponent(world, event, EventComponent{EventType: EventPlayerDisconnect}) - ecs.SetComponent(world, event, ConnectionIdComponent{ConnectionId: connectionId}) -} - -func CreatePlayerCommandEvent(world *ecs.World, connectionId uuid.UUID, command string) { - event := ecs.NewEntity() - - ecs.SetComponent(world, event, EventComponent{EventType: EventPlayerCommand}) - ecs.SetComponent(world, event, ConnectionIdComponent{ConnectionId: connectionId}) - ecs.SetComponent(world, event, CommandStringComponent{Command: command}) -} - -func CreateParseCommandEvent(world *ecs.World, command ecs.Entity) { - event := ecs.NewEntity() - - ecs.SetComponent(world, event, EventComponent{EventType: EventParseCommand}) - ecs.SetComponent(world, event, EntityComponent{Entity: command}) -} - -func CreateCommandExecutedEvent(world *ecs.World, command ecs.Entity) { - event := ecs.NewEntity() - - ecs.SetComponent(world, event, EventComponent{EventType: EventCommandExecuted}) - ecs.SetComponent(world, event, EntityComponent{Entity: command}) -} diff --git a/internal/game/data/output.go b/internal/game/data/output.go index a9c196b..e8f28f6 100644 --- a/internal/game/data/output.go +++ b/internal/game/data/output.go @@ -2,7 +2,6 @@ package data import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "github.com/google/uuid" ) type ContentsComponent struct { @@ -18,14 +17,3 @@ type CloseConnectionComponent struct{} func (cc CloseConnectionComponent) Type() ecs.ComponentType { return TypeCloseConnection } - -func CreateGameOutput(world *ecs.World, connectionId uuid.UUID, contents []byte, shouldClose bool) { - gameOutput := ecs.NewEntity() - - ecs.SetComponent(world, gameOutput, ConnectionIdComponent{ConnectionId: connectionId}) - ecs.SetComponent(world, gameOutput, ContentsComponent{Contents: contents}) - - if shouldClose { - ecs.SetComponent(world, gameOutput, CloseConnectionComponent{}) - } -} diff --git a/internal/game/data/player.go b/internal/game/data/player.go index 5ba4419..196ddbc 100644 --- a/internal/game/data/player.go +++ b/internal/game/data/player.go @@ -2,7 +2,6 @@ package data import ( "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "github.com/google/uuid" ) type PlayerState = byte @@ -36,21 +35,3 @@ type IsPlayerComponent struct{} func (c IsPlayerComponent) Type() ecs.ComponentType { return TypeIsPlayer } - -func CreatePlayer(world *ecs.World, id uuid.UUID, state PlayerState) (entity ecs.Entity, err error) { - entity = ecs.NewEntity() - - defaultRoom, err := ecs.GetResource[ecs.Entity](world, ResourceDefaultRoom) - - if err != nil { - return - } - - ecs.SetComponent(world, entity, ConnectionIdComponent{ConnectionId: id}) - ecs.SetComponent(world, entity, PlayerStateComponent{State: state}) - ecs.SetComponent(world, entity, NameComponent{Name: id.String()}) - ecs.SetComponent(world, entity, InRoomComponent{Room: defaultRoom}) - ecs.SetComponent(world, entity, IsPlayerComponent{}) - - return -} diff --git a/internal/game/data/resources.go b/internal/game/data/resources.go new file mode 100644 index 0000000..f2e72e9 --- /dev/null +++ b/internal/game/data/resources.go @@ -0,0 +1,7 @@ +package data + +import "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + +const ( + ResourceDefaultRoom ecs.Resource = "world:room:default" +) diff --git a/internal/game/data/room.go b/internal/game/data/room.go index 8309a40..a54ea30 100644 --- a/internal/game/data/room.go +++ b/internal/game/data/room.go @@ -16,18 +16,3 @@ type NeighborsComponent struct { func (c NeighborsComponent) Type() ecs.ComponentType { return TypeNeighbors } - -func CreateRoom( - world *ecs.World, - name, description string, - north, south, east, west ecs.Entity, -) ecs.Entity { - entity := ecs.NewEntity() - - ecs.SetComponent(world, entity, IsRoomComponent{}) - ecs.SetComponent(world, entity, NameComponent{Name: name}) - ecs.SetComponent(world, entity, DescriptionComponent{Description: description}) - ecs.SetComponent(world, entity, NeighborsComponent{North: north, South: south, East: east, West: west}) - - return entity -} diff --git a/internal/game/game.go b/internal/game/game.go index 436de7e..84b7831 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -1,56 +1,57 @@ package game import ( + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" "context" "sync" "time" "code.haedhutner.dev/mvv/LastMUD/internal/ecs" "code.haedhutner.dev/mvv/LastMUD/internal/game/data" - "code.haedhutner.dev/mvv/LastMUD/internal/game/systems" + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic" "code.haedhutner.dev/mvv/LastMUD/internal/logging" "github.com/google/uuid" ) -const TickRate = time.Duration(50 * time.Millisecond) +const TickRate = 50 * time.Millisecond -type GameOutput struct { +type Output struct { connId uuid.UUID contents []byte closeConnection bool } -func (g GameOutput) Id() uuid.UUID { +func (g Output) Id() uuid.UUID { return g.connId } -func (g GameOutput) Contents() []byte { +func (g Output) Contents() []byte { return g.contents } -func (g GameOutput) ShouldCloseConnection() bool { +func (g Output) ShouldCloseConnection() bool { return g.closeConnection } -type LastMUDGame struct { +type Game struct { ctx context.Context wg *sync.WaitGroup - world *data.GameWorld + world *World - output chan GameOutput + output chan Output } -func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) { - game = &LastMUDGame{ +func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *Game) { + game = &Game{ wg: wg, ctx: ctx, - output: make(chan GameOutput), - world: data.CreateGameWorld(), + output: make(chan Output), + world: CreateGameWorld(), } - ecs.RegisterSystems(game.world.World, systems.CreateSystems()...) + ecs.RegisterSystems(game.world.World, logic.CreateSystems()...) wg.Add(1) go game.start() @@ -58,8 +59,8 @@ func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) { return } -// Will block if no output present -func (game *LastMUDGame) ConsumeNextOutput() *GameOutput { +// ConsumeNextOutput will block if no output present +func (game *Game) ConsumeNextOutput() *Output { select { case output := <-game.output: return &output @@ -68,19 +69,19 @@ func (game *LastMUDGame) ConsumeNextOutput() *GameOutput { } } -func (game *LastMUDGame) ConnectPlayer(connectionId uuid.UUID) { - data.CreatePlayerConnectEvent(game.world.World, connectionId) +func (game *Game) ConnectPlayer(connectionId uuid.UUID) { + world.CreatePlayerConnectEvent(game.world.World, connectionId) } -func (game *LastMUDGame) DisconnectPlayer(connectionId uuid.UUID) { - data.CreatePlayerDisconnectEvent(game.world.World, connectionId) +func (game *Game) DisconnectPlayer(connectionId uuid.UUID) { + world.CreatePlayerDisconnectEvent(game.world.World, connectionId) } -func (game *LastMUDGame) SendPlayerCommand(connectionId uuid.UUID, command string) { - data.CreatePlayerCommandEvent(game.world.World, connectionId, command) +func (game *Game) SendPlayerCommand(connectionId uuid.UUID, command string) { + world.CreatePlayerCommandEvent(game.world.World, connectionId, command) } -func (game *LastMUDGame) start() { +func (game *Game) start() { defer game.wg.Done() defer game.shutdown() @@ -106,11 +107,11 @@ func (game *LastMUDGame) start() { } } -func (game *LastMUDGame) consumeOutputs() { +func (game *Game) consumeOutputs() { entities := ecs.FindEntitiesWithComponents(game.world.World, data.TypeConnectionId, data.TypeContents) for _, entity := range entities { - output := GameOutput{} + output := Output{} connId, _ := ecs.GetComponent[data.ConnectionIdComponent](game.world.World, entity) output.connId = connId.ConnectionId @@ -130,12 +131,12 @@ func (game *LastMUDGame) consumeOutputs() { ecs.DeleteEntities(game.world.World, entities...) } -func (game *LastMUDGame) shutdown() { +func (game *Game) shutdown() { logging.Info("Stopping LastMUD...") close(game.output) } -func (game *LastMUDGame) shouldStop() bool { +func (game *Game) shouldStop() bool { select { case <-game.ctx.Done(): return true @@ -144,11 +145,11 @@ func (game *LastMUDGame) shouldStop() bool { } } -func (game *LastMUDGame) enqeueOutput(output GameOutput) { +func (game *Game) enqeueOutput(output Output) { game.output <- output } -func (g *LastMUDGame) tick(delta time.Duration) { - g.world.Tick(delta) - g.consumeOutputs() +func (game *Game) tick(delta time.Duration) { + game.world.Tick(delta) + game.consumeOutputs() } diff --git a/internal/game/logic/command/command.go b/internal/game/logic/command/command.go new file mode 100644 index 0000000..a1d5b33 --- /dev/null +++ b/internal/game/logic/command/command.go @@ -0,0 +1,67 @@ +package command + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" + "fmt" + "time" + + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "code.haedhutner.dev/mvv/LastMUD/internal/logging" +) + +type commandError struct { + err string +} + +func createCommandError(v ...any) *commandError { + return &commandError{ + err: fmt.Sprint("Error handling command: ", v), + } +} + +func (e *commandError) Error() string { + return e.err +} + +type Handler func(w *ecs.World, delta time.Duration, player ecs.Entity, args map[string]data.Arg) (err error) + +func commandQuery(command data.Command) func(comp data.CommandComponent) bool { + return func(comp data.CommandComponent) bool { + return comp.Cmd == command + } +} + +func CreateHandler(command data.Command, handler Handler) ecs.SystemExecutor { + return func(w *ecs.World, delta time.Duration) (err error) { + commands := ecs.QueryEntitiesWithComponent(w, commandQuery(command)) + var processedCommands []ecs.Entity + + for c := range commands { + logging.Debug("Handling command of type ", command) + + player, _ := ecs.GetComponent[data.PlayerComponent](w, c) + args, _ := ecs.GetComponent[data.ArgsComponent](w, c) + + err := handler(w, delta, player.Player, args.Args) + + if err != nil { + logging.Info("Issue while handling command ", command, ": ", err) + + connId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, player.Player) + + world.CreateGameOutput(w, connId.ConnectionId, []byte(err.Error())) + } + + ecs.SetComponent(w, c, data.CommandStateComponent{State: data.CommandStateExecuted}) + + // data.CreateCommandExecutedEvent(world, c) // Not needed right now + + processedCommands = append(processedCommands, c) + } + + ecs.DeleteEntities(w, processedCommands...) + + return + } +} diff --git a/internal/game/logic/command/command_parsers.go b/internal/game/logic/command/command_parsers.go new file mode 100644 index 0000000..6c56245 --- /dev/null +++ b/internal/game/logic/command/command_parsers.go @@ -0,0 +1,84 @@ +package command + +import ( + "strings" + + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" +) + +func oneOf(value string, tests ...string) bool { + for _, t := range tests { + if value == t { + return true + } + } + + return false +} + +type commandParser = func(tokens []data.Token) (matches bool, args data.ArgsMap) + +var Parsers = map[data.Command]commandParser{ + data.CommandSay: func(tokens []data.Token) (matches bool, args data.ArgsMap) { + matches = len(tokens) > 1 + matches = matches && oneOf(tokens[0].Lexeme, "say", "lc", "localchat") + + if !matches { + return + } + + var lexemes []string + + for _, t := range tokens[1:] { + lexemes = append(lexemes, t.Lexeme) + } + + args = data.ArgsMap{ + data.ArgMessageContent: {Value: strings.Join(lexemes, " ")}, + } + + return + }, + data.CommandQuit: func(tokens []data.Token) (matches bool, args data.ArgsMap) { + matches = len(tokens) >= 1 + matches = matches && oneOf(tokens[0].Lexeme, "quit", "disconnect", "q", "leave") + + return + }, + data.CommandRegister: func(tokens []data.Token) (matches bool, args data.ArgsMap) { + matches = len(tokens) >= 3 + matches = matches && oneOf(tokens[0].Lexeme, "register", "signup") + + if !matches { + return + } + + accountName := tokens[1].Lexeme + accountPassword := tokens[2].Lexeme + + args = data.ArgsMap{ + data.ArgAccountName: {Value: accountName}, + data.ArgAccountPassword: {Value: accountPassword}, + } + + return + }, + data.CommandLogin: func(tokens []data.Token) (matches bool, args data.ArgsMap) { + matches = len(tokens) >= 3 + matches = matches && oneOf(tokens[0].Lexeme, "login", "signin") + + if !matches { + return + } + + accountName := tokens[1].Lexeme + accountPassword := tokens[2].Lexeme + + args = data.ArgsMap{ + data.ArgAccountName: {Value: accountName}, + data.ArgAccountPassword: {Value: accountPassword}, + } + + return + }, +} diff --git a/internal/game/logic/command/commands.go b/internal/game/logic/command/commands.go new file mode 100644 index 0000000..f86386e --- /dev/null +++ b/internal/game/logic/command/commands.go @@ -0,0 +1,63 @@ +package command + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" + "time" + + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" +) + +func HandleSay(w *ecs.World, _ time.Duration, player ecs.Entity, args data.ArgsMap) (err error) { + playerRoom, ok := ecs.GetComponent[data.InRoomComponent](w, player) + + if !ok { + return createCommandError("Player is not in any room!") + } + + playerName, ok := ecs.GetComponent[data.NameComponent](w, player) + + if !ok { + return createCommandError("Player has no name!") + } + + allPlayersInRoom := ecs.QueryEntitiesWithComponent(w, func(comp data.InRoomComponent) bool { + return comp.Room == playerRoom.Room + }) + + messageArg, ok := args[data.ArgMessageContent] + + if !ok { + return createCommandError("No message") + } + + message, ok := messageArg.Value.(string) + + if !ok { + return createCommandError("Can't interpret message as string") + } + + if message == "" { + return nil + } + + for p := range allPlayersInRoom { + connId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, p) + + world.CreateGameOutput(w, connId.ConnectionId, []byte(playerName.Name+": "+message)) + } + + return +} + +func HandleQuit(w *ecs.World, _ time.Duration, player ecs.Entity, _ data.ArgsMap) (err error) { + connId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, player) + + world.CreateClosingGameOutput(w, connId.ConnectionId, []byte("Goodbye!")) + + return +} + +func HandleRegister(world *ecs.World, delta time.Duration, player ecs.Entity, args map[data.ArgName]data.Arg) (err error) { + return +} diff --git a/internal/game/logic/event/command.go b/internal/game/logic/event/command.go new file mode 100644 index 0000000..792d4a1 --- /dev/null +++ b/internal/game/logic/event/command.go @@ -0,0 +1,165 @@ +package event + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/command" + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/world" + "fmt" + "regexp" +) + +type commandParseError struct { + err string +} + +func createCommandParseError(v ...any) *commandParseError { + return &commandParseError{ + err: fmt.Sprint("Error parsing command: ", v), + } +} + +func (e *commandParseError) Error() string { + return e.err +} + +func HandlePlayerCommand(w *ecs.World, event ecs.Entity) (err error) { + commandString, ok := ecs.GetComponent[data.CommandStringComponent](w, event) + + if !ok { + return createCommandParseError("No command string found for event") + } + + eventConnId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, event) + + if !ok { + return createCommandParseError("No connection id found for event") + } + + player := ecs.NilEntity() + + for p := range ecs.IterateEntitiesWithComponent[data.IsPlayerComponent](w) { + playerConnId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, p) + + if ok && playerConnId.ConnectionId == eventConnId.ConnectionId { + player = p + break + } + } + + if player == ecs.NilEntity() { + return createCommandParseError("Unable to find valid player with provided connection id") + } + + tokens, err := tokenize(commandString.Command) + + if err != nil { + return createCommandParseError("Error with tokenization: ", err) + } + + cmd := world.CreateTokenizedCommand(w, player, commandString.Command, tokens) + world.CreateParseCommandEvent(w, cmd) + + return +} + +func tokenize(commandString string) (tokens []data.Token, err error) { + tokens = []data.Token{} + pos := 0 + inputLen := len(commandString) + + // Continue iterating until we reach the end of the input + for pos < inputLen { + matched := false + remaining := commandString[pos:] + + // Iterate through each token type and test its pattern + for tokenType, pattern := range data.TokenPatterns { + // If the token pattern doesn't compile, panic ( why do we have invalid patterns?! ) + tokenPattern := regexp.MustCompile(pattern) + + // If the location of the match isn't nil, that means we've found a match + if loc := tokenPattern.FindStringIndex(remaining); loc != nil { + lexeme := remaining[loc[0]:loc[1]] + + pos += loc[1] + matched = true + + // Skip whitespace + if tokenType == data.TokenWhitespace { + break + } + + tokens = append( + tokens, + data.Token{ + Type: tokenType, + Lexeme: lexeme, + Index: pos, + }, + ) + + break + } + } + + // Unknown tokens are still added + if !matched { + tokens = append( + tokens, + data.Token{ + Type: data.TokenUnknown, + Lexeme: commandString[pos : pos+1], + Index: pos, + }, + ) + + pos++ + } + } + + // Mark the end of the tokens + tokens = append(tokens, data.Token{Type: data.TokenEOF, Lexeme: "", Index: pos}) + + return +} + +func HandleParseCommand(w *ecs.World, event ecs.Entity) (err error) { + cmdEnt, ok := ecs.GetComponent[data.EntityComponent](w, event) + + if !ok { + return createCommandParseError("No command entity provided in event") + } + + tokens, ok := ecs.GetComponent[data.TokensComponent](w, cmdEnt.Entity) + + if !ok { + return createCommandParseError("No tokens provided in command entity") + } + + var foundMatch bool + + for cmd, parser := range command.Parsers { + match, args := parser(tokens.Tokens) + + if !match { + continue + } + + ecs.SetComponent(w, cmdEnt.Entity, data.ArgsComponent{Args: args}) + ecs.SetComponent(w, cmdEnt.Entity, data.CommandComponent{Cmd: cmd}) + ecs.SetComponent(w, cmdEnt.Entity, data.CommandStateComponent{State: data.CommandStateParsed}) + + foundMatch = true + } + + player, _ := ecs.GetComponent[data.PlayerComponent](w, cmdEnt.Entity) + connectionId, _ := ecs.GetComponent[data.ConnectionIdComponent](w, player.Player) + + if !foundMatch { + world.CreateGameOutput(w, connectionId.ConnectionId, []byte("Unknown command")) + ecs.DeleteEntity(w, cmdEnt.Entity) + } + + return +} diff --git a/internal/game/systems/connection.go b/internal/game/logic/event/connection.go similarity index 59% rename from internal/game/systems/connection.go rename to internal/game/logic/event/connection.go index 60daedc..478a93b 100644 --- a/internal/game/systems/connection.go +++ b/internal/game/logic/event/connection.go @@ -1,37 +1,39 @@ -package systems +package event 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 handlePlayerConnectEvent(world *ecs.World, event ecs.Entity) (err error) { +func HandlePlayerConnect(w *ecs.World, event ecs.Entity) (err error) { logging.Info("Player connect") - connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](world, event) + connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, event) if !ok { return createEventHandlerError(data.EventPlayerConnect, "Event does not contain connectionId") } - data.CreatePlayer(world, connectionId.ConnectionId, data.PlayerStateJoining) - data.CreateGameOutput(world, connectionId.ConnectionId, []byte("Welcome to LastMUD!"), false) + 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).")) return } -func handlePlayerDisconnectEvent(world *ecs.World, event ecs.Entity) (err error) { +func HandlePlayerDisconnect(w *ecs.World, event ecs.Entity) (err error) { logging.Info("Player disconnect") - connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](world, event) + connectionId, ok := ecs.GetComponent[data.ConnectionIdComponent](w, event) if !ok { return createEventHandlerError(data.EventPlayerDisconnect, "Event does not contain connectionId") } playerEntity := ecs.QueryFirstEntityWithComponent( - world, + w, func(c data.ConnectionIdComponent) bool { return c.ConnectionId == connectionId.ConnectionId }, ) @@ -39,7 +41,7 @@ func handlePlayerDisconnectEvent(world *ecs.World, event ecs.Entity) (err error) return createEventHandlerError(data.EventPlayerDisconnect, "Connection id cannot be associated with a player entity") } - ecs.DeleteEntity(world, playerEntity) + ecs.DeleteEntity(w, playerEntity) return } diff --git a/internal/game/systems/event.go b/internal/game/logic/event/event.go similarity index 84% rename from internal/game/systems/event.go rename to internal/game/logic/event/event.go index 8aef9d1..497a182 100644 --- a/internal/game/systems/event.go +++ b/internal/game/logic/event/event.go @@ -1,4 +1,4 @@ -package systems +package event import ( "fmt" @@ -9,7 +9,7 @@ import ( "code.haedhutner.dev/mvv/LastMUD/internal/logging" ) -type EventHandler func(world *ecs.World, event ecs.Entity) (err error) +type Handler func(world *ecs.World, event ecs.Entity) (err error) type eventError struct { err string @@ -31,10 +31,10 @@ func eventTypeQuery(eventType data.EventType) func(comp data.EventComponent) boo } } -func CreateEventHandler(eventType data.EventType, handler EventHandler) ecs.SystemExecutor { +func CreateHandler(eventType data.EventType, handler Handler) ecs.SystemExecutor { return func(world *ecs.World, delta time.Duration) (err error) { events := ecs.QueryEntitiesWithComponent(world, eventTypeQuery(eventType)) - processedEvents := []ecs.Entity{} + var processedEvents []ecs.Entity for event := range events { logging.Debug("Handling event of type ", eventType) diff --git a/internal/game/logic/systems.go b/internal/game/logic/systems.go new file mode 100644 index 0000000..9342ac2 --- /dev/null +++ b/internal/game/logic/systems.go @@ -0,0 +1,28 @@ +package logic + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/command" + "code.haedhutner.dev/mvv/LastMUD/internal/game/logic/event" +) + +const ( + EventOffset = 0 + CommandOffset = 10000 +) + +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)), + + // 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)), + } +} diff --git a/internal/game/logic/world/account.go b/internal/game/logic/world/account.go new file mode 100644 index 0000000..a6d5b9b --- /dev/null +++ b/internal/game/logic/world/account.go @@ -0,0 +1,15 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" +) + +func CreateAccount(world *ecs.World, username, encryptedPassword string) ecs.Entity { + account := ecs.NewEntity() + + ecs.SetComponent(world, account, data.NameComponent{Name: username}) + ecs.SetComponent(world, account, data.PasswordComponent{EncryptedPassword: encryptedPassword}) + + return account +} diff --git a/internal/game/logic/world/command.go b/internal/game/logic/world/command.go new file mode 100644 index 0000000..7e3a76d --- /dev/null +++ b/internal/game/logic/world/command.go @@ -0,0 +1,17 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" +) + +func CreateTokenizedCommand(world *ecs.World, player ecs.Entity, commandString string, tokens []data.Token) ecs.Entity { + command := ecs.NewEntity() + + ecs.SetComponent(world, command, data.PlayerComponent{Player: player}) + ecs.SetComponent(world, command, data.CommandStringComponent{Command: commandString}) + ecs.SetComponent(world, command, data.TokensComponent{Tokens: tokens}) + ecs.SetComponent(world, command, data.CommandStateComponent{State: data.CommandStateTokenized}) + + return command +} diff --git a/internal/game/logic/world/events.go b/internal/game/logic/world/events.go new file mode 100644 index 0000000..43f2c29 --- /dev/null +++ b/internal/game/logic/world/events.go @@ -0,0 +1,43 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "github.com/google/uuid" +) + +func CreatePlayerConnectEvent(world *ecs.World, connectionId uuid.UUID) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventPlayerConnect}) + ecs.SetComponent(world, event, data.ConnectionIdComponent{ConnectionId: connectionId}) +} + +func CreatePlayerDisconnectEvent(world *ecs.World, connectionId uuid.UUID) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventPlayerDisconnect}) + ecs.SetComponent(world, event, data.ConnectionIdComponent{ConnectionId: connectionId}) +} + +func CreatePlayerCommandEvent(world *ecs.World, connectionId uuid.UUID, command string) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventPlayerCommand}) + ecs.SetComponent(world, event, data.ConnectionIdComponent{ConnectionId: connectionId}) + ecs.SetComponent(world, event, data.CommandStringComponent{Command: command}) +} + +func CreateParseCommandEvent(world *ecs.World, command ecs.Entity) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventParseCommand}) + ecs.SetComponent(world, event, data.EntityComponent{Entity: command}) +} + +func CreateCommandExecutedEvent(world *ecs.World, command ecs.Entity) { + event := ecs.NewEntity() + + ecs.SetComponent(world, event, data.EventComponent{EventType: data.EventCommandExecuted}) + ecs.SetComponent(world, event, data.EntityComponent{Entity: command}) +} diff --git a/internal/game/logic/world/output.go b/internal/game/logic/world/output.go new file mode 100644 index 0000000..13af4bb --- /dev/null +++ b/internal/game/logic/world/output.go @@ -0,0 +1,26 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "github.com/google/uuid" +) + +func CreateGameOutput(w *ecs.World, connectionId uuid.UUID, contents []byte) ecs.Entity { + gameOutput := ecs.NewEntity() + + ecs.SetComponent(w, gameOutput, data.ConnectionIdComponent{ConnectionId: connectionId}) + ecs.SetComponent(w, gameOutput, data.ContentsComponent{Contents: contents}) + + return gameOutput +} + +func CreateClosingGameOutput(w *ecs.World, connectionId uuid.UUID, contents []byte) ecs.Entity { + gameOutput := ecs.NewEntity() + + ecs.SetComponent(w, gameOutput, data.ConnectionIdComponent{ConnectionId: connectionId}) + ecs.SetComponent(w, gameOutput, data.ContentsComponent{Contents: contents}) + ecs.SetComponent(w, gameOutput, data.CloseConnectionComponent{}) + + return gameOutput +} diff --git a/internal/game/logic/world/player.go b/internal/game/logic/world/player.go new file mode 100644 index 0000000..b43c041 --- /dev/null +++ b/internal/game/logic/world/player.go @@ -0,0 +1,18 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" + "github.com/google/uuid" +) + +func CreateJoiningPlayer(world *ecs.World, connectionId uuid.UUID) (entity ecs.Entity) { + entity = ecs.NewEntity() + + ecs.SetComponent(world, entity, data.ConnectionIdComponent{ConnectionId: connectionId}) + ecs.SetComponent(world, entity, data.PlayerStateComponent{State: data.PlayerStateJoining}) + ecs.SetComponent(world, entity, data.NameComponent{Name: connectionId.String()}) + ecs.SetComponent(world, entity, data.IsPlayerComponent{}) + + return +} diff --git a/internal/game/logic/world/room.go b/internal/game/logic/world/room.go new file mode 100644 index 0000000..4934285 --- /dev/null +++ b/internal/game/logic/world/room.go @@ -0,0 +1,21 @@ +package world + +import ( + "code.haedhutner.dev/mvv/LastMUD/internal/ecs" + "code.haedhutner.dev/mvv/LastMUD/internal/game/data" +) + +func CreateRoom( + world *ecs.World, + name, description string, + north, south, east, west ecs.Entity, +) ecs.Entity { + entity := ecs.NewEntity() + + ecs.SetComponent(world, entity, data.IsRoomComponent{}) + ecs.SetComponent(world, entity, data.NameComponent{Name: name}) + ecs.SetComponent(world, entity, data.DescriptionComponent{Description: description}) + ecs.SetComponent(world, entity, data.NeighborsComponent{North: north, South: south, East: east, West: west}) + + return entity +} diff --git a/internal/game/systems/command.go b/internal/game/systems/command.go deleted file mode 100644 index 0d98f4a..0000000 --- a/internal/game/systems/command.go +++ /dev/null @@ -1,208 +0,0 @@ -package systems - -import ( - "fmt" - "regexp" - "time" - - "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "code.haedhutner.dev/mvv/LastMUD/internal/game/data" - "code.haedhutner.dev/mvv/LastMUD/internal/logging" -) - -type CommandHandler func(world *ecs.World, delta time.Duration, player ecs.Entity, args map[string]data.Arg) (err error) - -func commandQuery(command data.Command) func(comp data.CommandComponent) bool { - return func(comp data.CommandComponent) bool { - return comp.Cmd == command - } -} - -func CreateCommandHandler(command data.Command, handler CommandHandler) ecs.SystemExecutor { - return func(world *ecs.World, delta time.Duration) (err error) { - commands := ecs.QueryEntitiesWithComponent(world, commandQuery(command)) - processedCommands := []ecs.Entity{} - - for c := range commands { - logging.Debug("Handling command of type ", command) - - player, _ := ecs.GetComponent[data.PlayerComponent](world, c) - args, _ := ecs.GetComponent[data.ArgsComponent](world, c) - - err := handler(world, delta, player.Player, args.Args) - - if err != nil { - logging.Info("Issue while handling command ", command, ": ", err) - - connId, _ := ecs.GetComponent[data.ConnectionIdComponent](world, player.Player) - - data.CreateGameOutput(world, connId.ConnectionId, []byte(err.Error()), false) - } - - ecs.SetComponent(world, c, data.CommandStateComponent{State: data.CommandStateExecuted}) - - // data.CreateCommandExecutedEvent(world, c) // Not needed right now - - processedCommands = append(processedCommands, c) - } - - ecs.DeleteEntities(world, processedCommands...) - - return - } -} - -type commandError struct { - err string -} - -func createCommandError(v ...any) *commandError { - return &commandError{ - err: fmt.Sprint("Error handling command: ", v), - } -} - -func (e *commandError) Error() string { - return e.err -} - -func handlePlayerCommandEvent(world *ecs.World, event ecs.Entity) (err error) { - commandString, ok := ecs.GetComponent[data.CommandStringComponent](world, event) - - if !ok { - return createCommandError("Unable to handle command, no command string found for event") - } - - eventConnId, ok := ecs.GetComponent[data.ConnectionIdComponent](world, event) - - if !ok { - return createCommandError("Unable to handle command, no connection id found for event") - } - - player := ecs.NilEntity() - - for p := range ecs.IterateEntitiesWithComponent[data.IsPlayerComponent](world) { - playerConnId, ok := ecs.GetComponent[data.ConnectionIdComponent](world, p) - - if ok && playerConnId.ConnectionId == eventConnId.ConnectionId { - player = p - break - } - } - - if player == ecs.NilEntity() { - return createCommandError("Unable to find valid player with provided connection id") - } - - tokens, err := tokenize(commandString.Command) - - if err != nil { - return createCommandError("Error with tokenization: ", err) - } - - command := data.CreateTokenizedCommand(world, player, commandString.Command, tokens) - data.CreateParseCommandEvent(world, command) - - return -} - -func tokenize(commandString string) (tokens []data.Token, err error) { - tokens = []data.Token{} - pos := 0 - inputLen := len(commandString) - - // Continue iterating until we reach the end of the input - for pos < inputLen { - matched := false - remaining := commandString[pos:] - - // Iterate through each token type and test its pattern - for tokenType, pattern := range data.TokenPatterns { - // If the token pattern doesn't compile, panic ( why do we have invalid patterns?! ) - tokenPattern := regexp.MustCompile(pattern) - - // If the location of the match isn't nil, that means we've found a match - if loc := tokenPattern.FindStringIndex(remaining); loc != nil { - lexeme := remaining[loc[0]:loc[1]] - - pos += loc[1] - matched = true - - // Skip whitespace - if tokenType == data.TokenWhitespace { - break - } - - tokens = append( - tokens, - data.Token{ - Type: tokenType, - Lexeme: lexeme, - Index: pos, - }, - ) - - break - } - } - - // Unknown tokens are still added - if !matched { - tokens = append( - tokens, - data.Token{ - Type: data.TokenUnknown, - Lexeme: commandString[pos : pos+1], - Index: pos, - }, - ) - - pos++ - } - } - - // Mark the end of the tokens - tokens = append(tokens, data.Token{Type: data.TokenEOF, Lexeme: "", Index: pos}) - - return -} - -func parseCommand(world *ecs.World, event ecs.Entity) (err error) { - command, ok := ecs.GetComponent[data.EntityComponent](world, event) - - if !ok { - return createCommandError("Unable to parse command: no command entity provided in event") - } - - tokens, ok := ecs.GetComponent[data.TokensComponent](world, command.Entity) - - if !ok { - return createCommandError("Unable to parse command: no tokens provided in command entity") - } - - var foundMatch bool - - for cmd, parser := range commandParsers { - match, args := parser(tokens.Tokens) - - if !match { - continue - } - - ecs.SetComponent(world, command.Entity, data.ArgsComponent{Args: args}) - ecs.SetComponent(world, command.Entity, data.CommandComponent{Cmd: cmd}) - ecs.SetComponent(world, command.Entity, data.CommandStateComponent{State: data.CommandStateParsed}) - - foundMatch = true - } - - player, _ := ecs.GetComponent[data.PlayerComponent](world, command.Entity) - connectionId, _ := ecs.GetComponent[data.ConnectionIdComponent](world, player.Player) - - if !foundMatch { - data.CreateGameOutput(world, connectionId.ConnectionId, []byte("Unknown command"), false) - ecs.DeleteEntity(world, command.Entity) - } - - return -} diff --git a/internal/game/systems/command_parsers.go b/internal/game/systems/command_parsers.go deleted file mode 100644 index 44817b4..0000000 --- a/internal/game/systems/command_parsers.go +++ /dev/null @@ -1,48 +0,0 @@ -package systems - -import ( - "strings" - - "code.haedhutner.dev/mvv/LastMUD/internal/game/data" -) - -func oneOf(value string, tests ...string) bool { - for _, t := range tests { - if value == t { - return true - } - } - - return false -} - -type commandParser = func(tokens []data.Token) (matches bool, args map[string]data.Arg) - -var commandParsers = map[data.Command]commandParser{ - data.CommandSay: func(tokens []data.Token) (matches bool, args map[string]data.Arg) { - matches = len(tokens) > 1 - matches = matches && oneOf(tokens[0].Lexeme, "say", "lc", "localchat") - - if !matches { - return - } - - lexemes := []string{} - - for _, t := range tokens[1:] { - lexemes = append(lexemes, t.Lexeme) - } - - args = map[string]data.Arg{ - "messageContent": {Value: strings.Join(lexemes, " ")}, - } - - return - }, - data.CommandQuit: func(tokens []data.Token) (matches bool, args map[string]data.Arg) { - matches = len(tokens) >= 1 - matches = matches && oneOf(tokens[0].Lexeme, "quit", "disconnect", "q", "leave") - - return - }, -} diff --git a/internal/game/systems/commands.go b/internal/game/systems/commands.go deleted file mode 100644 index 99c0370..0000000 --- a/internal/game/systems/commands.go +++ /dev/null @@ -1,60 +0,0 @@ -package systems - -import ( - "time" - - "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "code.haedhutner.dev/mvv/LastMUD/internal/game/data" -) - -func handleSayCommand(world *ecs.World, delta time.Duration, player ecs.Entity, args map[string]data.Arg) (err error) { - playerRoom, ok := ecs.GetComponent[data.InRoomComponent](world, player) - - if !ok { - return createCommandError("Player is not in any room!") - } - - playerName, ok := ecs.GetComponent[data.NameComponent](world, player) - - if !ok { - return createCommandError("Player has no name!") - } - - allPlayersInRoom := ecs.QueryEntitiesWithComponent(world, func(comp data.InRoomComponent) bool { - return comp.Room == playerRoom.Room - }) - - messageArg, ok := args["messageContent"] - - if !ok { - return createCommandError("No message") - } - - message, ok := messageArg.Value.(string) - - if !ok { - return createCommandError("Can't interpret message as string") - } - - if message == "" { - return nil - } - - for p := range allPlayersInRoom { - connId, _ := ecs.GetComponent[data.ConnectionIdComponent](world, p) - - data.CreateGameOutput(world, connId.ConnectionId, []byte(playerName.Name+": "+message), false) - } - - return -} - -func handleQuitCommand(world *ecs.World, delta time.Duration, player ecs.Entity, _ map[string]data.Arg) (err error) { - connId, _ := ecs.GetComponent[data.ConnectionIdComponent](world, player) - - data.CreateGameOutput(world, connId.ConnectionId, []byte("Goodbye!"), true) - - data.CreatePlayerDisconnectEvent(world, connId.ConnectionId) - - return -} diff --git a/internal/game/systems/systems.go b/internal/game/systems/systems.go deleted file mode 100644 index 2408e13..0000000 --- a/internal/game/systems/systems.go +++ /dev/null @@ -1,24 +0,0 @@ -package systems - -import ( - "code.haedhutner.dev/mvv/LastMUD/internal/ecs" - "code.haedhutner.dev/mvv/LastMUD/internal/game/data" -) - -type SystemPriorityOffset = int - -const ( - EventOffset = 0 - CommandOffset = 10000 -) - -func CreateSystems() []*ecs.System { - return []*ecs.System{ - ecs.CreateSystem("PlayerConnectEventHandler", EventOffset+0, CreateEventHandler(data.EventPlayerConnect, handlePlayerConnectEvent)), - ecs.CreateSystem("PlayerDisconnectEventHandler", EventOffset+1, CreateEventHandler(data.EventPlayerDisconnect, handlePlayerDisconnectEvent)), - ecs.CreateSystem("PlayerCommandEventHandler", EventOffset+2, CreateEventHandler(data.EventPlayerCommand, handlePlayerCommandEvent)), - ecs.CreateSystem("ParseCommandEventHandler", EventOffset+4, CreateEventHandler(data.EventParseCommand, parseCommand)), - ecs.CreateSystem("SayCommandHandler", CommandOffset+0, CreateCommandHandler(data.CommandSay, handleSayCommand)), - ecs.CreateSystem("QuitCommandHandler", CommandOffset+1, CreateCommandHandler(data.CommandQuit, handleQuitCommand)), - } -} diff --git a/internal/game/data/world.go b/internal/game/world.go similarity index 72% rename from internal/game/data/world.go rename to internal/game/world.go index 13edab1..1f0ae96 100644 --- a/internal/game/data/world.go +++ b/internal/game/world.go @@ -1,19 +1,17 @@ -package data +package game 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" ) -const ( - ResourceDefaultRoom ecs.Resource = "world:room:default" -) - -type GameWorld struct { +type World struct { *ecs.World } -func CreateGameWorld() (gw *GameWorld) { - gw = &GameWorld{ +func CreateGameWorld() (gw *World) { + gw = &World{ World: ecs.CreateWorld(), } @@ -22,9 +20,9 @@ func CreateGameWorld() (gw *GameWorld) { return } -func defineRooms(world *ecs.World) { - forest := CreateRoom( - world, +func defineRooms(w *ecs.World) { + forest := world.CreateRoom( + w, "Forest", "A dense, misty forest stretches endlessly, its towering trees whispering secrets through rustling leaves. Sunbeams filter through the canopy, dappling the mossy ground with golden light.", ecs.NilEntity(), @@ -33,10 +31,10 @@ func defineRooms(world *ecs.World) { ecs.NilEntity(), ) - ecs.SetResource(world, ResourceDefaultRoom, forest) + ecs.SetResource(w, data.ResourceDefaultRoom, forest) - cabin := CreateRoom( - world, + cabin := world.CreateRoom( + w, "Wooden Cabin", "The cabin’s interior is cozy and rustic, with wooden beams overhead and a stone fireplace crackling warmly. A wool rug lies on creaky floorboards, and shelves brim with books, mugs, and old lanterns.", ecs.NilEntity(), @@ -45,8 +43,8 @@ func defineRooms(world *ecs.World) { ecs.NilEntity(), ) - lake := CreateRoom( - world, + lake := world.CreateRoom( + w, "Ethermere Lake", "Ethermire Lake lies shrouded in mist, its dark, still waters reflecting a sky perpetually overcast. Whispers ride the wind, and strange lights flicker beneath the surface, never breaking it.", ecs.NilEntity(), @@ -55,8 +53,8 @@ func defineRooms(world *ecs.World) { ecs.NilEntity(), ) - graveyard := CreateRoom( - world, + graveyard := world.CreateRoom( + w, "Graveyard", "An overgrown graveyard shrouded in fog, with cracked headstones and leaning statues. The wind sighs through dead trees, and unseen footsteps echo faintly among the mossy graves.", ecs.NilEntity(), @@ -65,8 +63,8 @@ func defineRooms(world *ecs.World) { ecs.NilEntity(), ) - chapel := CreateRoom( - world, + chapel := world.CreateRoom( + w, "Chapel of the Hollow Light", "This ruined chapel leans under ivy and age. Faint light filters through shattered stained glass, casting broken rainbows across dust-choked pews and a long-silent altar.", ecs.NilEntity(), @@ -75,32 +73,32 @@ func defineRooms(world *ecs.World) { ecs.NilEntity(), ) - ecs.SetComponent(world, forest, NeighborsComponent{ + ecs.SetComponent(w, forest, data.NeighborsComponent{ North: cabin, South: graveyard, East: lake, West: chapel, }) - ecs.SetComponent(world, cabin, NeighborsComponent{ + ecs.SetComponent(w, cabin, data.NeighborsComponent{ South: graveyard, West: chapel, East: lake, }) - ecs.SetComponent(world, chapel, NeighborsComponent{ + ecs.SetComponent(w, chapel, data.NeighborsComponent{ North: cabin, South: graveyard, East: forest, }) - ecs.SetComponent(world, lake, NeighborsComponent{ + ecs.SetComponent(w, lake, data.NeighborsComponent{ West: forest, North: cabin, South: graveyard, }) - ecs.SetComponent(world, graveyard, NeighborsComponent{ + ecs.SetComponent(w, graveyard, data.NeighborsComponent{ North: forest, West: chapel, East: lake, diff --git a/internal/server/connection.go b/internal/server/connection.go index 3860c5f..a449e45 100644 --- a/internal/server/connection.go +++ b/internal/server/connection.go @@ -27,6 +27,8 @@ type Connection struct { conn *net.TCPConn lastSeen time.Time + + closeChan chan struct{} } func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup) (c *Connection) { @@ -36,12 +38,13 @@ func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg conn.SetKeepAlivePeriod(1 * time.Second) c = &Connection{ - ctx: ctx, - wg: wg, - server: server, - identity: uuid.New(), - conn: conn, - lastSeen: time.Now(), + ctx: ctx, + wg: wg, + server: server, + identity: uuid.New(), + conn: conn, + lastSeen: time.Now(), + closeChan: make(chan struct{}, 1), } c.wg.Add(2) @@ -115,8 +118,19 @@ func (c *Connection) shouldClose() bool { case <-c.ctx.Done(): return true default: - return false } + + select { + case <-c.closeChan: + return true + default: + } + + return false +} + +func (c *Connection) CommandClose() { + c.closeChan <- struct{}{} } func (c *Connection) closeConnection() { diff --git a/internal/server/server.go b/internal/server/server.go index 019d488..af7d728 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,7 +19,7 @@ type Server struct { connections map[uuid.UUID]*Connection - lastmudgame *game.LastMUDGame + lastmudgame *game.Game } func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Server, err error) { @@ -63,7 +63,7 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se return } -func (srv *Server) game() *game.LastMUDGame { +func (srv *Server) game() *game.Game { return srv.lastmudgame } @@ -118,7 +118,7 @@ func (srv *Server) consumeGameOutput() { } if output.ShouldCloseConnection() { - conn.closeConnection() + conn.CommandClose() delete(srv.connections, output.Id()) } }