No longer blocks randomly

This commit is contained in:
Miroslav Vasilev 2025-06-22 17:54:07 +03:00
parent 2f926d5457
commit 45abc33c7f
10 changed files with 345 additions and 83 deletions

47
internal/game/event.go Normal file
View file

@ -0,0 +1,47 @@
package game
import "time"
type EventType int
const (
PlayerJoin EventType = iota
PlayerCommand
PlayerLeave
)
type GameEvent interface {
Type() EventType
Handle(game *LastMUDGame, delta time.Duration)
}
type EventBus struct {
events chan GameEvent
}
func CreateEventBus() *EventBus {
return &EventBus{
events: make(chan GameEvent, 10),
}
}
func (eb *EventBus) HasNext() bool {
return len(eb.events) > 0
}
func (eb *EventBus) Pop() (event GameEvent) {
select {
case event := <-eb.events:
return event
default:
return nil
}
}
func (eb *EventBus) Push(event GameEvent) {
eb.events <- event
}
func (eb *EventBus) close() {
close(eb.events)
}

44
internal/game/events.go Normal file
View file

@ -0,0 +1,44 @@
package game
import (
"time"
"github.com/google/uuid"
)
type PlayerJoinEvent struct {
connectionId uuid.UUID
}
func CreatePlayerJoinEvent(connId uuid.UUID) *PlayerJoinEvent {
return &PlayerJoinEvent{
connectionId: connId,
}
}
func (pje *PlayerJoinEvent) Type() EventType {
return PlayerJoin
}
func (pje *PlayerJoinEvent) Handle(game *LastMUDGame, delta time.Duration) {
game.world.AddPlayerToDefaultRoom(CreatePlayer(pje.connectionId, nil))
game.enqeueOutput(CreateOutput(pje.connectionId, []byte("Welcome to LastMUD\n")))
}
type PlayerLeaveEvent struct {
connectionId uuid.UUID
}
func CreatePlayerLeaveEvent(connId uuid.UUID) *PlayerLeaveEvent {
return &PlayerLeaveEvent{
connectionId: connId,
}
}
func (ple *PlayerLeaveEvent) Type() EventType {
return PlayerJoin
}
func (ple *PlayerLeaveEvent) Handle(game *LastMUDGame, delta time.Duration) {
game.world.RemovePlayerById(ple.connectionId.String())
}

View file

@ -6,22 +6,49 @@ import (
"time" "time"
"code.haedhutner.dev/mvv/LastMUD/internal/logging" "code.haedhutner.dev/mvv/LastMUD/internal/logging"
"github.com/google/uuid"
) )
const TickRate = time.Duration(50 * time.Millisecond) const TickRate = time.Duration(50 * time.Millisecond)
type GameSignal struct { type GameOutput struct {
connId uuid.UUID
contents []byte
}
func CreateOutput(connId uuid.UUID, contents []byte) GameOutput {
return GameOutput{
connId: connId,
contents: contents,
}
}
func (g GameOutput) Id() uuid.UUID {
return g.connId
}
func (g GameOutput) Contents() []byte {
return g.contents
} }
type LastMUDGame struct { type LastMUDGame struct {
ctx context.Context ctx context.Context
wg *sync.WaitGroup wg *sync.WaitGroup
world *World
eventBus *EventBus
output chan GameOutput
} }
func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) { func CreateGame(ctx context.Context, wg *sync.WaitGroup) (game *LastMUDGame) {
game = &LastMUDGame{ game = &LastMUDGame{
wg: wg, wg: wg,
ctx: ctx, ctx: ctx,
eventBus: CreateEventBus(),
output: make(chan GameOutput, 10),
world: CreateWorld(),
} }
wg.Add(1) wg.Add(1)
@ -58,6 +85,8 @@ func (game *LastMUDGame) start() {
func (game *LastMUDGame) shutdown() { func (game *LastMUDGame) shutdown() {
logging.Info("Stopping LastMUD...") logging.Info("Stopping LastMUD...")
close(game.output)
game.eventBus.close()
} }
func (game *LastMUDGame) shouldStop() bool { func (game *LastMUDGame) shouldStop() bool {
@ -69,7 +98,31 @@ func (game *LastMUDGame) shouldStop() bool {
} }
} }
func (g *LastMUDGame) tick(delta time.Duration) { func (game *LastMUDGame) EnqueueEvent(event GameEvent) {
// logging.Debug("Tick") game.eventBus.Push(event)
// TODO }
func (game *LastMUDGame) enqeueOutput(output GameOutput) {
game.output <- output
}
func (game *LastMUDGame) ConsumeNextOutput() *GameOutput {
select {
case output := <-game.output:
return &output
default:
return nil
}
}
func (g *LastMUDGame) tick(delta time.Duration) {
for {
event := g.eventBus.Pop()
if event == nil {
return
}
event.Handle(g, delta)
}
} }

View file

@ -1,19 +1,24 @@
package game package game
import "github.com/google/uuid"
type Player struct { type Player struct {
GameObject id uuid.UUID
Name
Description currentRoom *Room
Position
Velocity
} }
func CreatePlayer(name, description string, x, y int) *Player { func CreatePlayer(identity uuid.UUID, room *Room) *Player {
return &Player{ return &Player{
GameObject: CreateGameObject(), id: identity,
Name: WithName(name), currentRoom: room,
Description: WithDescription(description),
Position: WithPosition(x, y),
Velocity: WithVelocity(0, 0),
} }
} }
func (p *Player) Identity() string {
return p.id.String()
}
func (p *Player) SetRoom(r *Room) {
p.currentRoom = r
}

42
internal/game/room.go Normal file
View file

@ -0,0 +1,42 @@
package game
type RoomPlayer interface {
Identity() string
SetRoom(room *Room)
}
type Room struct {
North *Room
South *Room
East *Room
West *Room
Name string
Description string
players map[string]RoomPlayer
}
func CreateRoom(name, description string) *Room {
return &Room{
Name: name,
Description: description,
players: map[string]RoomPlayer{},
}
}
func (r *Room) PlayerJoinRoom(player RoomPlayer) (err error) {
r.players[player.Identity()] = player
return
}
func (r *Room) PlayerLeaveRoom(player RoomPlayer) (err error) {
delete(r.players, player.Identity())
return
}
func (r *Room) Players() map[string]RoomPlayer {
return r.players
}

View file

@ -1,45 +1 @@
package game package game
import "github.com/google/uuid"
type GameObject struct {
uuid uuid.UUID
}
func CreateGameObject() GameObject {
return GameObject{
uuid: uuid.New(),
}
}
type Position struct {
x, y int
}
func WithPosition(x, y int) Position {
return Position{x, y}
}
type Velocity struct {
velX, velY int
}
func WithVelocity(velX, velY int) Velocity {
return Velocity{velX, velY}
}
type Name struct {
name string
}
func WithName(name string) Name {
return Name{name}
}
type Description struct {
description string
}
func WithDescription(description string) Description {
return Description{description}
}

64
internal/game/world.go Normal file
View file

@ -0,0 +1,64 @@
package game
type World struct {
rooms []*Room
players map[string]*Player
defaultRoom *Room
}
func CreateWorld() *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.")
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.")
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.North = cabin
forest.South = graveyard
forest.East = lake
forest.West = chapel
cabin.South = forest
cabin.West = chapel
cabin.East = lake
chapel.North = cabin
chapel.South = graveyard
chapel.East = forest
lake.West = forest
lake.North = cabin
lake.South = graveyard
graveyard.North = forest
graveyard.West = chapel
graveyard.East = lake
return &World{
rooms: []*Room{
forest,
cabin,
lake,
graveyard,
chapel,
},
defaultRoom: forest,
players: map[string]*Player{},
}
}
func (w *World) AddPlayerToDefaultRoom(p *Player) {
w.players[p.Identity()] = p
w.defaultRoom.PlayerJoinRoom(p)
p.SetRoom(w.defaultRoom)
}
func (w *World) RemovePlayerById(id string) {
p, ok := w.players[id]
if ok {
p.currentRoom.PlayerLeaveRoom(p)
delete(w.players, id)
return
}
}

View file

@ -79,10 +79,16 @@ func CreateLogger(maxFileLevel LogLevel, maxDisplayedLevel LogLevel, filePath st
logFilePath := fmt.Sprintf("%s-%s%s", base, timestamp, ext) // "./base/dir/log-2006-01-02_15-04-05.txt" logFilePath := fmt.Sprintf("%s-%s%s", base, timestamp, ext) // "./base/dir/log-2006-01-02_15-04-05.txt"
mkdirErr := os.MkdirAll(filepath.Dir(logFilePath), 0755)
if mkdirErr != nil {
err(os.Stdout, false, timestampFormat, mkdirErr)
}
file, fileErr := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) file, fileErr := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if fileErr != nil { if fileErr != nil {
err(os.Stdout, false, "Logging: Unable to write to file", filePath, fileErr) err(os.Stdout, false, timestampFormat, fileErr)
} else { } else {
logger.file = file logger.file = file
} }

View file

@ -7,25 +7,29 @@ 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
wg *sync.WaitGroup wg *sync.WaitGroup
server *Server
identity uuid.UUID identity uuid.UUID
conn *net.TCPConn conn *net.TCPConn
lastSeen time.Time lastSeen time.Time
inputChannel chan string inputChannel chan []byte
} }
func CreateConnection(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) {
logging.Info("Connect: ", conn.RemoteAddr()) logging.Info("Connect: ", conn.RemoteAddr())
conn.SetKeepAlive(true) conn.SetKeepAlive(true)
@ -34,9 +38,10 @@ func CreateConnection(conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup
c = &Connection{ c = &Connection{
ctx: ctx, ctx: ctx,
wg: wg, wg: wg,
server: server,
identity: uuid.New(), identity: uuid.New(),
conn: conn, conn: conn,
inputChannel: make(chan string), inputChannel: make(chan []byte, MaxEnqueuedInputMessages),
lastSeen: time.Now(), lastSeen: time.Now(),
} }
@ -44,9 +49,15 @@ func CreateConnection(conn *net.TCPConn, ctx context.Context, wg *sync.WaitGroup
go c.listen() go c.listen()
go c.checkAlive() go c.checkAlive()
server.game.EnqueueEvent(game.CreatePlayerJoinEvent(c.Id()))
return return
} }
func (c *Connection) Id() uuid.UUID {
return c.identity
}
func (c *Connection) listen() { func (c *Connection) listen() {
defer c.wg.Done() defer c.wg.Done()
@ -55,13 +66,18 @@ func (c *Connection) listen() {
for { for {
c.conn.SetReadDeadline(time.Time{}) c.conn.SetReadDeadline(time.Time{})
message, err := bufio.NewReader(c.conn).ReadString('\n') message, err := bufio.NewReader(c.conn).ReadBytes('\n')
if err != nil { if err != nil {
logging.Warn(err) logging.Warn(err)
break break
} }
if len(c.inputChannel) == MaxEnqueuedInputMessages {
c.conn.Write([]byte("You have too many commands enqueued. Please wait until some are processed.\n"))
continue
}
c.inputChannel <- message c.inputChannel <- message
c.conn.Write([]byte(message)) c.conn.Write([]byte(message))
@ -76,12 +92,12 @@ func (c *Connection) checkAlive() {
for { for {
if c.shouldClose() { if c.shouldClose() {
c.Write("Server shutting down, bye bye!\r\n") c.Write([]byte("Server shutting down, bye bye!\r\n"))
break break
} }
if time.Since(c.lastSeen) > MaxLastSeenTime { if time.Since(c.lastSeen) > MaxLastSeenTime {
c.Write("You have been away for too long, bye bye!\r\n") c.Write([]byte("You have been away for too long, bye bye!\r\n"))
break break
} }
@ -103,21 +119,25 @@ 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()))
logging.Info("Disconnected: ", c.conn.RemoteAddr()) logging.Info("Disconnected: ", c.conn.RemoteAddr())
} }
func (c *Connection) NextInput() (input string, err error) { func (c *Connection) NextInput() (input []byte, err error) {
select { select {
case val := <-c.inputChannel: case val := <-c.inputChannel:
return val, nil return val, nil
default: default:
return "", newInputEmptyError() return nil, newInputEmptyError()
} }
} }
func (c *Connection) Write(output string) (err error) { func (c *Connection) Write(output []byte) (err error) {
_, err = c.conn.Write([]byte(output)) _, err = c.conn.Write(output)
return return
} }

View file

@ -8,6 +8,7 @@ import (
"code.haedhutner.dev/mvv/LastMUD/internal/game" "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"
) )
type Server struct { type Server struct {
@ -16,7 +17,7 @@ type Server struct {
listener *net.TCPListener listener *net.TCPListener
connections []*Connection connections map[uuid.UUID]*Connection
game *game.LastMUDGame game *game.LastMUDGame
} }
@ -50,13 +51,14 @@ func CreateServer(ctx context.Context, wg *sync.WaitGroup, port string) (srv *Se
ctx: ctx, ctx: ctx,
wg: wg, wg: wg,
listener: ln, listener: ln,
connections: []*Connection{}, connections: map[uuid.UUID]*Connection{},
} }
srv.game = game.CreateGame(ctx, srv.wg) srv.game = game.CreateGame(ctx, srv.wg)
srv.wg.Add(1) srv.wg.Add(2)
go srv.listen() go srv.listen()
go srv.consumeGameOutput()
return return
} }
@ -85,8 +87,31 @@ func (srv *Server) listen() {
continue continue
} }
c := CreateConnection(tcpConn, srv.ctx, srv.wg) c := CreateConnection(srv, tcpConn, srv.ctx, srv.wg)
srv.connections = append(srv.connections, c)
srv.connections[c.Id()] = c
}
}
func (srv *Server) consumeGameOutput() {
defer srv.wg.Done()
for {
if srv.shouldStop() {
break
}
output := srv.game.ConsumeNextOutput()
if output == nil {
continue
}
conn, ok := srv.connections[output.Id()]
if ok {
conn.Write(output.Contents())
}
} }
} }