Basic chatting

This commit is contained in:
Miroslav Vasilev 2025-06-22 22:27:56 +03:00
parent 45abc33c7f
commit b8d8127bc0
14 changed files with 224 additions and 97 deletions

View file

@ -12,22 +12,12 @@ func CreateCommand(cmdDef CommandDefinition, parameters []Parameter) Command {
} }
} }
func (cmd Command) Execute() (err error) { func (cmd Command) Definition() CommandDefinition {
return cmd.commandDefinition.work(cmd.params...) return cmd.commandDefinition
} }
type commandContextError struct { func (cmd Command) Parameters() []Parameter {
err string return cmd.params
}
func createCommandContextError(err string) *commandContextError {
return &commandContextError{
err: err,
}
}
func (cce *commandContextError) Error() string {
return cce.err
} }
type CommandContext struct { type CommandContext struct {
@ -65,6 +55,6 @@ func CreateCommandContext(commandRegistry *CommandRegistry, commandString string
return return
} }
func (ctx *CommandContext) ExecuteCommand() (err error) { func (ctx *CommandContext) Command() Command {
return ctx.command.Execute() return ctx.command
} }

View file

@ -17,3 +17,17 @@ func createCommandError(cmdName string, msg string, msgArgs ...any) *commandErro
func (cmdErr *commandError) Error() string { func (cmdErr *commandError) Error() string {
return "Error with command '" + cmdErr.cmdName + "': " + cmdErr.message return "Error with command '" + cmdErr.cmdName + "': " + cmdErr.message
} }
type commandContextError struct {
err string
}
func createCommandContextError(err string) *commandContextError {
return &commandContextError{
err: err,
}
}
func (cce *commandContextError) Error() string {
return cce.err
}

View file

@ -1,6 +1,8 @@
package command package command
import "log" import (
"code.haedhutner.dev/mvv/LastMUD/internal/logging"
)
type TokenMatcher func(tokens []Token) bool type TokenMatcher func(tokens []Token) bool
@ -12,20 +14,17 @@ type CommandDefinition struct {
name string name string
tokenMatcher TokenMatcher tokenMatcher TokenMatcher
parameterParser ParameterParser parameterParser ParameterParser
work CommandWork
} }
func CreateCommandDefinition( func CreateCommandDefinition(
name string, name string,
tokenMatcher TokenMatcher, tokenMatcher TokenMatcher,
parameterParser ParameterParser, parameterParser ParameterParser,
work CommandWork,
) CommandDefinition { ) CommandDefinition {
return CommandDefinition{ return CommandDefinition{
name: name, name: name,
tokenMatcher: tokenMatcher, tokenMatcher: tokenMatcher,
parameterParser: parameterParser, parameterParser: parameterParser,
work: work,
} }
} }
@ -41,10 +40,6 @@ func (def CommandDefinition) ParseParameters(tokens []Token) []Parameter {
return def.parameterParser(tokens) return def.parameterParser(tokens)
} }
func (def CommandDefinition) ExecuteFunc() CommandWork {
return def.work
}
type CommandRegistry struct { type CommandRegistry struct {
commandDefinitions []CommandDefinition commandDefinitions []CommandDefinition
} }
@ -55,14 +50,10 @@ func CreateCommandRegistry(commandDefinitions ...CommandDefinition) *CommandRegi
} }
} }
func (comReg *CommandRegistry) Register(newCommandDefinitions ...CommandDefinition) {
comReg.commandDefinitions = append(comReg.commandDefinitions, newCommandDefinitions...)
}
func (comReg *CommandRegistry) Match(tokens []Token) (comDef *CommandDefinition) { func (comReg *CommandRegistry) Match(tokens []Token) (comDef *CommandDefinition) {
for _, v := range comReg.commandDefinitions { for _, v := range comReg.commandDefinitions {
if v.Match(tokens) { if v.Match(tokens) {
log.Println("Found match", v.Name()) logging.Debug("Found match", v.Name())
return &v return &v
} }
} }

31
internal/game/commands.go Normal file
View file

@ -0,0 +1,31 @@
package game
import "code.haedhutner.dev/mvv/LastMUD/internal/game/command"
type CommandType = string
const (
SayCommand CommandType = "say"
)
func (game *LastMUDGame) CreateGameCommandRegistry() *command.CommandRegistry {
return command.CreateCommandRegistry(
command.CreateCommandDefinition(
SayCommand,
func(tokens []command.Token) bool {
return len(tokens) > 1 && tokens[0].Lexeme() == "say"
},
func(tokens []command.Token) []command.Parameter {
saying := ""
for _, t := range tokens[1:] {
saying += t.Lexeme()
}
return []command.Parameter{
command.CreateParameter(saying),
}
},
),
)
}

View file

@ -1,6 +1,10 @@
package game package game
import "time" import (
"time"
"code.haedhutner.dev/mvv/LastMUD/internal/logging"
)
type EventType int type EventType int
@ -8,6 +12,8 @@ const (
PlayerJoin EventType = iota PlayerJoin EventType = iota
PlayerCommand PlayerCommand
PlayerLeave PlayerLeave
PlayerSpeak
) )
type GameEvent interface { type GameEvent interface {
@ -32,6 +38,7 @@ func (eb *EventBus) HasNext() bool {
func (eb *EventBus) Pop() (event GameEvent) { func (eb *EventBus) Pop() (event GameEvent) {
select { select {
case event := <-eb.events: case event := <-eb.events:
logging.Info("Popped event of type ", event.Type(), ":", event)
return event return event
default: default:
return nil return nil
@ -40,6 +47,7 @@ func (eb *EventBus) Pop() (event GameEvent) {
func (eb *EventBus) Push(event GameEvent) { func (eb *EventBus) Push(event GameEvent) {
eb.events <- event eb.events <- event
logging.Info("Enqueued event of type ", event.Type(), ":", event)
} }
func (eb *EventBus) close() { func (eb *EventBus) close() {

View file

@ -3,6 +3,8 @@ package game
import ( import (
"time" "time"
"code.haedhutner.dev/mvv/LastMUD/internal/game/command"
"code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -10,7 +12,7 @@ type PlayerJoinEvent struct {
connectionId uuid.UUID connectionId uuid.UUID
} }
func CreatePlayerJoinEvent(connId uuid.UUID) *PlayerJoinEvent { func (game *LastMUDGame) CreatePlayerJoinEvent(connId uuid.UUID) *PlayerJoinEvent {
return &PlayerJoinEvent{ return &PlayerJoinEvent{
connectionId: connId, connectionId: connId,
} }
@ -22,23 +24,90 @@ func (pje *PlayerJoinEvent) Type() EventType {
func (pje *PlayerJoinEvent) Handle(game *LastMUDGame, delta time.Duration) { func (pje *PlayerJoinEvent) Handle(game *LastMUDGame, delta time.Duration) {
game.world.AddPlayerToDefaultRoom(CreatePlayer(pje.connectionId, nil)) game.world.AddPlayerToDefaultRoom(CreatePlayer(pje.connectionId, nil))
game.enqeueOutput(CreateOutput(pje.connectionId, []byte("Welcome to LastMUD\n"))) game.enqeueOutput(game.CreateOutput(pje.connectionId, []byte("Welcome to LastMUD\n")))
} }
type PlayerLeaveEvent struct { type PlayerLeaveEvent struct {
connectionId uuid.UUID connectionId uuid.UUID
} }
func CreatePlayerLeaveEvent(connId uuid.UUID) *PlayerLeaveEvent { func (game *LastMUDGame) CreatePlayerLeaveEvent(connId uuid.UUID) *PlayerLeaveEvent {
return &PlayerLeaveEvent{ return &PlayerLeaveEvent{
connectionId: connId, connectionId: connId,
} }
} }
func (ple *PlayerLeaveEvent) Type() EventType { func (ple *PlayerLeaveEvent) Type() EventType {
return PlayerJoin return PlayerLeave
} }
func (ple *PlayerLeaveEvent) Handle(game *LastMUDGame, delta time.Duration) { func (ple *PlayerLeaveEvent) Handle(game *LastMUDGame, delta time.Duration) {
game.world.RemovePlayerById(ple.connectionId.String()) game.world.RemovePlayerById(ple.connectionId)
}
type PlayerCommandEvent struct {
connectionId uuid.UUID
command *command.CommandContext
}
func (game *LastMUDGame) CreatePlayerCommandEvent(connId uuid.UUID, cmdString string) (event *PlayerCommandEvent, err error) {
cmdCtx, err := command.CreateCommandContext(game.CommandRegistry(), cmdString)
if err != nil {
return nil, err
}
event = &PlayerCommandEvent{
connectionId: connId,
command: cmdCtx,
}
return
}
func (pce *PlayerCommandEvent) Type() EventType {
return PlayerCommand
}
func (pce *PlayerCommandEvent) Handle(game *LastMUDGame, delta time.Duration) {
player := game.world.FindPlayerById(pce.connectionId)
if player == nil {
logging.Error("Unable to handle player command from player with id", pce.connectionId, ": Player does not exist")
return
}
switch pce.command.Command().Definition().Name() {
case SayCommand:
speech, err := pce.command.Command().Parameters()[0].AsString()
if err != nil {
logging.Error("Unable to handle player speech from player with id", pce.connectionId, ": Speech could not be parsed: ", err.Error())
return
}
game.EnqueueEvent(game.CreatePlayerSayEvent(player, speech))
}
}
type PlayerSayEvent struct {
player *Player
speech string
}
func (game *LastMUDGame) CreatePlayerSayEvent(player *Player, speech string) *PlayerSayEvent {
return &PlayerSayEvent{
player: player,
speech: speech,
}
}
func (pse *PlayerSayEvent) Type() EventType {
return PlayerSpeak
}
func (pse *PlayerSayEvent) Handle(game *LastMUDGame, delta time.Duration) {
for _, p := range pse.player.CurrentRoom().Players() {
game.enqeueOutput(game.CreateOutput(p.Identity(), []byte(pse.player.id.String()+" in "+pse.player.CurrentRoom().Name+": "+pse.speech)))
}
} }

View file

@ -5,6 +5,7 @@ import (
"sync" "sync"
"time" "time"
"code.haedhutner.dev/mvv/LastMUD/internal/game/command"
"code.haedhutner.dev/mvv/LastMUD/internal/logging" "code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -16,7 +17,7 @@ type GameOutput struct {
contents []byte contents []byte
} }
func CreateOutput(connId uuid.UUID, contents []byte) GameOutput { func (game *LastMUDGame) CreateOutput(connId uuid.UUID, contents []byte) GameOutput {
return GameOutput{ return GameOutput{
connId: connId, connId: connId,
contents: contents, contents: contents,
@ -35,7 +36,8 @@ type LastMUDGame struct {
ctx context.Context ctx context.Context
wg *sync.WaitGroup wg *sync.WaitGroup
world *World commandRegistry *command.CommandRegistry
world *World
eventBus *EventBus eventBus *EventBus
@ -51,6 +53,8 @@ func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) {
world: CreateWorld(), world: CreateWorld(),
} }
game.commandRegistry = game.CreateGameCommandRegistry()
wg.Add(1) wg.Add(1)
go game.start() go game.start()
@ -115,6 +119,10 @@ func (game *LastMUDGame) ConsumeNextOutput() *GameOutput {
} }
} }
func (game *LastMUDGame) CommandRegistry() *command.CommandRegistry {
return game.commandRegistry
}
func (g *LastMUDGame) tick(delta time.Duration) { func (g *LastMUDGame) tick(delta time.Duration) {
for { for {
event := g.eventBus.Pop() event := g.eventBus.Pop()

View file

@ -15,10 +15,14 @@ func CreatePlayer(identity uuid.UUID, room *Room) *Player {
} }
} }
func (p *Player) Identity() string { func (p *Player) Identity() uuid.UUID {
return p.id.String() return p.id
} }
func (p *Player) SetRoom(r *Room) { func (p *Player) SetRoom(r *Room) {
p.currentRoom = r p.currentRoom = r
} }
func (p *Player) CurrentRoom() *Room {
return p.currentRoom
}

View file

@ -1,11 +1,15 @@
package game package game
import "github.com/google/uuid"
type RoomPlayer interface { type RoomPlayer interface {
Identity() string Identity() uuid.UUID
SetRoom(room *Room) SetRoom(room *Room)
} }
type Room struct { type Room struct {
world *World
North *Room North *Room
South *Room South *Room
East *Room East *Room
@ -14,14 +18,15 @@ type Room struct {
Name string Name string
Description string Description string
players map[string]RoomPlayer players map[uuid.UUID]RoomPlayer
} }
func CreateRoom(name, description string) *Room { func CreateRoom(world *World, name, description string) *Room {
return &Room{ return &Room{
world: world,
Name: name, Name: name,
Description: description, Description: description,
players: map[string]RoomPlayer{}, players: map[uuid.UUID]RoomPlayer{},
} }
} }
@ -37,6 +42,10 @@ func (r *Room) PlayerLeaveRoom(player RoomPlayer) (err error) {
return return
} }
func (r *Room) Players() map[string]RoomPlayer { func (r *Room) Players() map[uuid.UUID]RoomPlayer {
return r.players return r.players
} }
func (r *Room) World() *World {
return r.world
}

View file

@ -1,17 +1,23 @@
package game package game
import "github.com/google/uuid"
type World struct { type World struct {
rooms []*Room rooms []*Room
players map[string]*Player players map[uuid.UUID]*Player
defaultRoom *Room defaultRoom *Room
} }
func CreateWorld() *World { func CreateWorld() (world *World) {
forest := CreateRoom("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.") world = &World{
cabin := CreateRoom("Wooden Cabin", "The cabins 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.") players: map[uuid.UUID]*Player{},
lake := CreateRoom("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.") }
graveyard := CreateRoom("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.")
chapel := CreateRoom("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.") forest := CreateRoom(world, "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.")
cabin := CreateRoom(world, "Wooden Cabin", "The cabins 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.")
lake := CreateRoom(world, "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.")
graveyard := CreateRoom(world, "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.")
chapel := CreateRoom(world, "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.")
forest.North = cabin forest.North = cabin
forest.South = graveyard forest.South = graveyard
@ -34,17 +40,17 @@ func CreateWorld() *World {
graveyard.West = chapel graveyard.West = chapel
graveyard.East = lake graveyard.East = lake
return &World{ world.rooms = []*Room{
rooms: []*Room{ forest,
forest, cabin,
cabin, lake,
lake, graveyard,
graveyard, chapel,
chapel,
},
defaultRoom: forest,
players: map[string]*Player{},
} }
world.defaultRoom = forest
return
} }
func (w *World) AddPlayerToDefaultRoom(p *Player) { func (w *World) AddPlayerToDefaultRoom(p *Player) {
@ -53,7 +59,7 @@ func (w *World) AddPlayerToDefaultRoom(p *Player) {
p.SetRoom(w.defaultRoom) p.SetRoom(w.defaultRoom)
} }
func (w *World) RemovePlayerById(id string) { func (w *World) RemovePlayerById(id uuid.UUID) {
p, ok := w.players[id] p, ok := w.players[id]
if ok { if ok {
@ -62,3 +68,13 @@ func (w *World) RemovePlayerById(id string) {
return return
} }
} }
func (w *World) FindPlayerById(id uuid.UUID) *Player {
p, ok := w.players[id]
if ok {
return p
} else {
return nil
}
}

View file

@ -7,13 +7,11 @@ import (
"sync" "sync"
"time" "time"
"code.haedhutner.dev/mvv/LastMUD/internal/game"
"code.haedhutner.dev/mvv/LastMUD/internal/logging" "code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid" "github.com/google/uuid"
) )
const MaxLastSeenTime = 120 * time.Second const MaxLastSeenTime = 120 * time.Second
const MaxEnqueuedInputMessages = 10
type Connection struct { type Connection struct {
ctx context.Context ctx context.Context
@ -25,8 +23,6 @@ type Connection struct {
conn *net.TCPConn conn *net.TCPConn
lastSeen time.Time lastSeen time.Time
inputChannel chan []byte
} }
func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup) (c *Connection) { func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup) (c *Connection) {
@ -36,20 +32,19 @@ func CreateConnection(server *Server, conn *net.TCPConn, ctx context.Context, wg
conn.SetKeepAlivePeriod(1 * time.Second) conn.SetKeepAlivePeriod(1 * time.Second)
c = &Connection{ c = &Connection{
ctx: ctx, ctx: ctx,
wg: wg, wg: wg,
server: server, server: server,
identity: uuid.New(), identity: uuid.New(),
conn: conn, conn: conn,
inputChannel: make(chan []byte, MaxEnqueuedInputMessages), lastSeen: time.Now(),
lastSeen: time.Now(),
} }
c.wg.Add(2) c.wg.Add(2)
go c.listen() go c.listen()
go c.checkAlive() go c.checkAlive()
server.game.EnqueueEvent(game.CreatePlayerJoinEvent(c.Id())) server.game().EnqueueEvent(server.game().CreatePlayerJoinEvent(c.Id()))
return return
} }
@ -66,22 +61,21 @@ func (c *Connection) listen() {
for { for {
c.conn.SetReadDeadline(time.Time{}) c.conn.SetReadDeadline(time.Time{})
message, err := bufio.NewReader(c.conn).ReadBytes('\n') message, err := bufio.NewReader(c.conn).ReadString('\n')
if err != nil { if err != nil {
logging.Warn(err) logging.Warn(err)
break break
} }
if len(c.inputChannel) == MaxEnqueuedInputMessages { event, err := c.server.game().CreatePlayerCommandEvent(c.Id(), message)
c.conn.Write([]byte("You have too many commands enqueued. Please wait until some are processed.\n"))
continue if err != nil {
c.conn.Write([]byte(err.Error() + "\n"))
} else {
c.server.game().EnqueueEvent(event)
} }
c.inputChannel <- message
c.conn.Write([]byte(message))
c.lastSeen = time.Now() c.lastSeen = time.Now()
} }
} }
@ -119,24 +113,13 @@ func (c *Connection) shouldClose() bool {
} }
func (c *Connection) closeConnection() { func (c *Connection) closeConnection() {
close(c.inputChannel)
c.conn.Close() c.conn.Close()
c.server.game.EnqueueEvent(game.CreatePlayerLeaveEvent(c.Id())) c.server.game().EnqueueEvent(c.server.game().CreatePlayerLeaveEvent(c.Id()))
logging.Info("Disconnected: ", c.conn.RemoteAddr()) logging.Info("Disconnected: ", c.conn.RemoteAddr())
} }
func (c *Connection) NextInput() (input []byte, err error) {
select {
case val := <-c.inputChannel:
return val, nil
default:
return nil, newInputEmptyError()
}
}
func (c *Connection) Write(output []byte) (err error) { func (c *Connection) Write(output []byte) (err error) {
_, err = c.conn.Write(output) _, err = c.conn.Write(output)
return return

View file

@ -19,7 +19,7 @@ type Server struct {
connections map[uuid.UUID]*Connection connections map[uuid.UUID]*Connection
game *game.LastMUDGame lastmudgame *game.LastMUDGame
} }
func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Server, err error) { func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Server, err error) {
@ -54,7 +54,7 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se
connections: map[uuid.UUID]*Connection{}, connections: map[uuid.UUID]*Connection{},
} }
srv.game = game.CreateGame(ctx, srv.wg) srv.lastmudgame = game.CreateGame(ctx, srv.wg)
srv.wg.Add(2) srv.wg.Add(2)
go srv.listen() go srv.listen()
@ -63,6 +63,10 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se
return return
} }
func (srv *Server) game() *game.LastMUDGame {
return srv.lastmudgame
}
func (srv *Server) listen() { func (srv *Server) listen() {
defer srv.wg.Done() defer srv.wg.Done()
defer srv.shutdown() defer srv.shutdown()
@ -101,7 +105,7 @@ func (srv *Server) consumeGameOutput() {
break break
} }
output := srv.game.ConsumeNextOutput() output := srv.lastmudgame.ConsumeNextOutput()
if output == nil { if output == nil {
continue continue