summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorraven <citrons@mondecitronne.com>2026-03-20 14:29:52 -0500
committerraven <citrons@mondecitronne.com>2026-03-20 14:29:52 -0500
commitc3d63652a4b80add587ee17f5c9f3773417203ad (patch)
treee26e1d36f912f1bca210e1aaa3e314668d0b010b /server
initial commit
Diffstat (limited to 'server')
-rw-r--r--server/chat.go61
-rw-r--r--server/commands.go1
-rw-r--r--server/coords.go34
-rw-r--r--server/data.go87
-rw-r--r--server/level.go266
-rw-r--r--server/player.go242
-rw-r--r--server/server.go396
7 files changed, 1087 insertions, 0 deletions
diff --git a/server/chat.go b/server/chat.go
new file mode 100644
index 0000000..a32da17
--- /dev/null
+++ b/server/chat.go
@@ -0,0 +1,61 @@
+package server
+
+import (
+ "strings"
+ "git.citrons.xyz/metronode/classic"
+)
+
+func processChatMessage(message string) []classic.Packet {
+ var (
+ packets []classic.Packet
+ in = strings.NewReader(message)
+ line strings.Builder
+ word strings.Builder
+ color byte = 'f'
+ )
+ for in.Len() > 0 {
+ for {
+ b, err := in.ReadByte()
+ if b > 127 {
+ b = '?'
+ }
+ if err == nil {
+ if b == '&' || b == '%' {
+ if word.Len() + 1 >= 64 {
+ line.WriteString(word.String())
+ word.Reset()
+ }
+ next, err := in.ReadByte()
+ in.UnreadByte()
+ if err != nil {
+ word.WriteString("& ")
+ } else if (next >= '0' && next <= '9') ||
+ (next >= 'a' && next <= 'f') {
+ b = '&'
+ color = next
+ }
+ }
+ word.WriteByte(b)
+ }
+ if line.Len() + word.Len() > 64 {
+ break
+ }
+ if err != nil {
+ line.WriteString(word.String())
+ word.Reset()
+ break
+ }
+ if word.Len() >= 64 || b == ' ' {
+ line.WriteString(word.String())
+ word.Reset()
+ }
+ }
+ packet := &classic.Message {
+ Message: classic.PadString(line.String()),
+ }
+ packets = append(packets, packet)
+ line.Reset()
+ line.WriteString("&f> &" + string([]byte{color}))
+ }
+ return packets
+}
diff --git a/server/commands.go b/server/commands.go
new file mode 100644
index 0000000..abb4e43
--- /dev/null
+++ b/server/commands.go
@@ -0,0 +1 @@
+package server
diff --git a/server/coords.go b/server/coords.go
new file mode 100644
index 0000000..ebe71c9
--- /dev/null
+++ b/server/coords.go
@@ -0,0 +1,34 @@
+package server
+
+const playerHeight = 51
+const blockSize = 32
+
+type blockCoord int64
+type entityCoord int64
+
+type entityFacing struct {
+ Yaw, Pitch uint8
+}
+
+type blockPos struct {
+ X, Y, Z blockCoord
+}
+type entityPos struct {
+ X, Y, Z entityCoord
+}
+
+func entityToBlock(pos entityPos) blockPos {
+ return blockPos {
+ blockCoord(pos.X >> 5),
+ blockCoord(pos.Y >> 5),
+ blockCoord(pos.Z >> 5),
+ }
+}
+
+func blockToEntity(pos blockPos) entityPos {
+ return entityPos {
+ entityCoord(pos.X << 5),
+ entityCoord(pos.Y << 5),
+ entityCoord(pos.Z << 5),
+ }
+}
diff --git a/server/data.go b/server/data.go
new file mode 100644
index 0000000..b7ed280
--- /dev/null
+++ b/server/data.go
@@ -0,0 +1,87 @@
+package server
+
+import (
+ "os"
+ "encoding/binary"
+ "git.citrons.xyz/metronode/phony"
+)
+
+var dataManager struct {
+ phony.Inbox
+ errHand interface {
+ OnSaveError(from phony.Actor, err error)
+ OnLoadError(from phony.Actor, err error)
+ }
+}
+
+type atomicWriter struct {
+ from phony.Actor
+ name string
+ tmpFile *os.File
+}
+
+func createAtomic(from phony.Actor, name string) (atomicWriter, error) {
+ os.Mkdir("world", 0777)
+ f, err := os.CreateTemp("world", "*.tmp")
+ if err != nil {
+ dataManager.errHand.OnSaveError(from, err)
+ return atomicWriter {}, err
+ }
+ return atomicWriter {from, name, f}, nil
+}
+
+func (f atomicWriter) Write(b []byte) (n int, err error) {
+ return f.tmpFile.Write(b)
+}
+
+func (f atomicWriter) Close() error {
+ if f.tmpFile == nil {
+ return nil
+ }
+ dataManager.Act(f.from, func() {
+ err := f.tmpFile.Close()
+ if err != nil {
+ dataManager.errHand.OnSaveError(&dataManager, err)
+ return
+ }
+ err = os.Rename(f.tmpFile.Name(), f.name)
+ if err != nil {
+ dataManager.errHand.OnSaveError(&dataManager, err)
+ }
+ })
+ return nil
+}
+
+func saveDataFile[T any](from phony.Actor, name string, data T) {
+ f, err := createAtomic(from, "world/" + name + ".bin")
+ if err != nil {
+ dataManager.errHand.OnSaveError(from, err)
+ return
+ }
+ defer f.Close()
+ err = binary.Write(f, binary.BigEndian, data)
+ if err != nil {
+ dataManager.errHand.OnSaveError(from, err)
+ }
+}
+
+func loadDataFile[T any](
+ from phony.Actor, name string, reply func(data T, ok bool)) {
+ dataManager.Act(from, func() {
+ f, err := os.Open("world/" + name + ".bin")
+ if err != nil {
+ if !os.IsNotExist(err) {
+ dataManager.errHand.OnLoadError(&dataManager, err)
+ }
+ return
+ }
+ defer f.Close()
+ var data T
+ err = binary.Read(f, binary.BigEndian, &data)
+ if err != nil {
+ dataManager.errHand.OnLoadError(&dataManager, err)
+ }
+ from.Act(nil, func() {reply(data, err == nil)})
+ })
+}
+
diff --git a/server/level.go b/server/level.go
new file mode 100644
index 0000000..577d0dc
--- /dev/null
+++ b/server/level.go
@@ -0,0 +1,266 @@
+package server
+
+import (
+ "io"
+ "os"
+ "fmt"
+ "bytes"
+ "compress/gzip"
+ "encoding/binary"
+ "git.citrons.xyz/metronode/phony"
+)
+
+type levelId int32
+type blockType byte
+type levelPlayerId int8
+type levelInfo struct {
+ Id levelId
+ Size blockPos
+ IsSpawn bool
+}
+
+type level struct {
+ phony.Inbox
+ levelInfo
+ loadingState int
+ server *Server
+ blocks []byte
+ ids map[levelPlayerId]*player
+ players map[*player]levelPlayerId
+}
+
+const (
+ levelUnloaded = iota
+ levelLoading
+ levelLoaded
+)
+
+func newLevel(s *Server, info levelInfo) *level {
+ return &level {
+ levelInfo: info,
+ server: s,
+ loadingState: levelLoaded,
+ blocks: make([]byte, info.Size.X * info.Size.Y * info.Size.Z),
+ ids: make(map[levelPlayerId]*player),
+ players: make(map[*player]levelPlayerId),
+ }
+}
+
+func loadLevel(s *Server, id levelId, isSpawn bool) *level {
+ l := &level {
+ levelInfo: levelInfo {Id: id, IsSpawn: isSpawn},
+ server: s,
+ ids: make(map[levelPlayerId]*player),
+ players: make(map[*player]levelPlayerId),
+ }
+ l.load()
+ return l
+}
+
+func (l *level) load() {
+ if l.loadingState == levelUnloaded {
+ l.loadingState = levelLoading
+ id := l.Id
+ dataManager.Act(l, func() {
+ f, err := os.Open(fmt.Sprintf("world/level/%d.bin", id))
+ if l.OnLevelError(&dataManager, err) != nil {
+ return
+ }
+ defer f.Close()
+ var (
+ info levelInfo
+ blocks []byte
+ )
+ err = binary.Read(f, binary.BigEndian, &info)
+ if l.OnLevelError(&dataManager, err) != nil {
+ return
+ }
+ z, err := gzip.NewReader(f)
+ if l.OnLevelError(&dataManager, err) != nil {
+ return
+ }
+ z.Read(make([]byte, 4))
+ blocks, err = io.ReadAll(z)
+ if l.OnLevelError(&dataManager, err) != nil {
+ return
+ }
+ l.Act(&dataManager, func() {
+ l.loadingState = levelLoaded
+ l.levelInfo = info
+ l.Id = id
+ l.blocks = blocks
+ for player := range l.players {
+ player.OnLevelData(l, l.levelInfo, l.compressLevelData())
+ }
+ })
+ })
+ }
+}
+
+func (l *level) OnLevelError(from phony.Actor, err error) error {
+ if err == nil {
+ return err
+ }
+ l.Act(from, func() {
+ l.loadingState = levelUnloaded
+ dataManager.errHand.OnLoadError(l, err)
+ errString := err.Error()
+ if os.IsNotExist(err) {
+ errString = "level not found"
+ }
+ for player := range l.players {
+ player.OnLevelError(l, errString, l.levelInfo)
+ }
+ })
+ return err
+}
+
+func (l *level) save(done func()) {
+ if l.loadingState != levelLoaded {
+ return
+ }
+ os.Mkdir("world/level", 0777)
+ f, err := createAtomic(l, fmt.Sprintf("world/level/%d.bin", l.Id))
+ if err != nil {
+ dataManager.errHand.OnSaveError(l, err)
+ return
+ }
+ defer f.Close()
+
+ err = binary.Write(f, binary.BigEndian, l.levelInfo)
+ if err != nil {
+ dataManager.errHand.OnSaveError(l, err)
+ return
+ }
+ data := l.compressLevelData()
+ _, err = io.Copy(f, data)
+ if err != nil {
+ dataManager.errHand.OnSaveError(l, err)
+ return
+ }
+ if done != nil {
+ dataManager.Act(nil, done)
+ }
+}
+
+func (l *level) blockIndex(pos blockPos) int {
+ return int(pos.X + pos.Z*l.Size.X + pos.Y*l.Size.X*l.Size.Z)
+}
+
+func (l *level) setBlock(pos blockPos, block blockType) {
+ l.blocks[l.blockIndex(pos)] = byte(block)
+}
+
+func (l *level) getBlock(pos blockPos) blockType {
+ return blockType(l.blocks[l.blockIndex(pos)])
+}
+
+func (l *level) compressLevelData() io.Reader {
+ rd, wr := io.Pipe()
+ data := bytes.NewReader(bytes.Clone(l.blocks))
+ go func() {
+ defer wr.Close()
+ z := gzip.NewWriter(wr)
+ defer z.Close()
+ binary.Write(z, binary.BigEndian, uint32(len(l.blocks)))
+ io.Copy(z, data)
+ }()
+ return rd
+}
+
+func (l *level) generateFlat() {
+ var p blockPos
+ for p.Z = 0; p.Z < l.levelInfo.Size.Z; p.Z++ {
+ for p.Y = 0; p.Y < l.levelInfo.Size.Y / 2; p.Y++ {
+ for p.X = 0; p.X < l.levelInfo.Size.X; p.X++ {
+ var block blockType
+ if p.Y == 0 {
+ block = 7
+ } else if p.Y == l.levelInfo.Size.Y/2 - 1 {
+ block = 2
+ } else if p.Y > l.levelInfo.Size.Y/2 - 15 {
+ block = 3
+ } else {
+ block = 1
+ }
+ l.setBlock(p, block)
+ }
+ }
+ }
+}
+
+func (l *level) Save(from phony.Actor, done func()) {
+ l.Act(from, func() {
+ l.save(func() {
+ if done != nil {
+ from.Act(nil, done)
+ }
+ })
+ })
+}
+
+func (l *level) SetBlock(from phony.Actor, pos blockPos, block blockType) {
+ if l.loadingState != levelLoaded {
+ return
+ }
+ l.Act(from, func() {
+ l.setBlock(pos, block)
+ for player := range l.players {
+ player.OnSetBlock(l, pos, block)
+ }
+ })
+}
+
+func (l *level) OnAddPlayer(from *player, name string, pos entityPos) {
+ l.Act(from, func() {
+ l.load()
+ if l.loadingState == levelLoaded {
+ from.OnLevelData(l, l.levelInfo, l.compressLevelData())
+ }
+ var newId levelPlayerId
+ for newId = 0; newId <= 127; newId++ {
+ if l.ids[newId] == nil {
+ break
+ }
+ }
+ if newId == -1 {
+ from.OnLevelError(
+ l, "there are too many players in this area", l.levelInfo,
+ )
+ }
+ for player, id := range l.players {
+ if player != from {
+ player.OnPlayer(l, newId, name, pos)
+ player.GetInfo(l,
+ func(name string, state playerState) {
+ from.OnPlayer(l, id, name, state.Pos)
+ },
+ )
+ }
+ }
+ l.ids[newId] = from
+ l.players[from] = newId
+ })
+}
+
+func (l *level) OnRemovePlayer(from *player) {
+ l.Act(from, func() {
+ delId := l.players[from]
+ delete(l.ids, delId)
+ delete(l.players, from)
+ for player := range l.players {
+ player.OnRemovePlayer(l, delId)
+ }
+ })
+}
+
+func (l *level) OnMovePlayer(
+ from *player, pos entityPos, facing entityFacing) {
+ l.Act(from, func() {
+ for player := range l.players {
+ if player != from {
+ player.OnMovePlayer(l, l.players[from], pos, facing)
+ }
+ }
+ })
+}
diff --git a/server/player.go b/server/player.go
new file mode 100644
index 0000000..bc6ee38
--- /dev/null
+++ b/server/player.go
@@ -0,0 +1,242 @@
+package server
+
+import (
+ "io"
+ "os"
+ "fmt"
+ "regexp"
+ "git.citrons.xyz/metronode/phony"
+ "git.citrons.xyz/metronode/classic"
+)
+
+type player struct {
+ phony.Inbox
+ state playerState
+ client *client
+ server *Server
+ name string
+ level *level
+}
+
+type playerState struct {
+ LevelId levelId
+ Pos entityPos
+ Facing entityFacing
+}
+
+var playerNameRegex = regexp.MustCompile("^[.-_a-zA-Z0-9]*$")
+
+func newPlayer(s *Server, cl *client, name string) *player {
+ pl := &player {client: cl, server: s, name: name}
+ loadDataFile(pl, "player/" + name, func(state playerState, ok bool) {
+ if ok {
+ pl.state = state
+ pl.ChangeLevel(pl, state.LevelId, state.Pos)
+ } else {
+ s.SendToSpawn(pl, pl)
+ }
+ })
+ return pl
+}
+
+func (p *player) save(done func()) {
+ os.Mkdir("world/player", 0777)
+ saveDataFile(p, "player/" + p.name, p.state)
+ if done != nil {
+ dataManager.Act(nil, done)
+ }
+}
+
+func (p *player) kick(reason string) {
+ p.save(nil)
+ p.client.Disconnect(p, reason)
+ if p.level != nil {
+ p.level.OnRemovePlayer(p)
+ p.level = nil
+ }
+}
+
+func (p *player) handlePacket(packet classic.Packet) {
+ if p.level == nil {
+ return
+ }
+ switch pck := packet.(type) {
+ case *classic.SetPosFacing:
+ p.state.Pos = entityPos {
+ entityCoord(pck.X), entityCoord(pck.Y), entityCoord(pck.Z),
+ }
+ p.state.Facing = entityFacing {pck.Yaw, pck.Pitch}
+ p.level.OnMovePlayer(p, p.state.Pos, p.state.Facing)
+ case *classic.ClientSetBlock:
+ block := blockType(pck.Block)
+ if pck.Mode == classic.BlockDestroyed {
+ block = 0
+ }
+ pos := blockPos {
+ blockCoord(pck.X),
+ blockCoord(pck.Y),
+ blockCoord(pck.Z),
+ }
+ p.level.SetBlock(p, pos, block)
+ case *classic.Message:
+ p.server.OnPlayerMessage(p, p.name, classic.UnpadString(pck.Message))
+ }
+}
+
+func (p *player) Save(from phony.Actor, done func()) {
+ p.Act(from, func() {
+ p.save(func() {
+ if done != nil {
+ from.Act(nil, done)
+ }
+ })
+ })
+}
+
+func (p *player) Kick(from phony.Actor, reason string) {
+ p.Act(from, func() {
+ p.kick(reason)
+ })
+}
+
+func (p *player) OnPacket(from phony.Actor, packet classic.Packet) {
+ p.Act(from, func() {
+ p.handlePacket(packet)
+ })
+}
+
+func (p *player) joinLevel(id levelId, lvl *level, pos entityPos) {
+ lvl.OnAddPlayer(p, p.name, pos)
+ p.level = lvl
+ p.state.LevelId = id
+ p.state.Pos = pos
+}
+
+func (p *player) ChangeLevel(from phony.Actor, lvl levelId, pos entityPos) {
+ p.Act(from, func() {
+ if p.level != nil {
+ p.level.OnRemovePlayer(p)
+ }
+ p.server.GetLevel(p, lvl, func(l *level) {
+ p.joinLevel(lvl, l, pos)
+ })
+ })
+}
+
+func (p *player) MovePlayer(
+ from phony.Actor, pos entityPos, facing entityFacing) {
+ p.Act(from, func() {
+ p.level.OnMovePlayer(p, pos, facing)
+ })
+}
+
+func (p *player) GetInfo(from phony.Actor,
+ reply func(name string, state playerState)) {
+ p.Act(from, func() {
+ name := p.name
+ state := p.state
+ from.Act(nil, func() {reply(name, state)})
+ })
+}
+
+func (p *player) SendMessage(from phony.Actor, message string) {
+ p.Act(from, func() {
+ p.client.SendPackets(p, processChatMessage(message))
+ })
+}
+
+func (p *player) OnPlayerMessage(from *Server, name string, message string) {
+ p.SendMessage(from, fmt.Sprintf("&7<&b%s&7>&f %s", name, message))
+}
+
+func (p *player) OnLevelData(from *level, info levelInfo, data io.Reader) {
+ p.Act(from, func() {
+ if from != p.level {
+ return
+ }
+ var packets []classic.Packet
+ for {
+ var packet classic.LevelDataChunk
+ n, err := io.ReadFull(data, packet.Data[:])
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ if n == 0 {
+ break
+ }
+ } else if err != nil {
+ panic(err)
+ }
+ packet.Length = int16(n)
+ packets = append(packets, &packet)
+ }
+ for i := 0; i < len(packets); i++ {
+ chunk := packets[i].(*classic.LevelDataChunk)
+ chunk.PercentComplete = byte(i * 100 / len(packets))
+ }
+ p.client.SendPackets(p, packets)
+ p.client.SendPacket(p, &classic.LevelFinalize {
+ Width: int16(info.Size.X),
+ Height: int16(info.Size.Y),
+ Length: int16(info.Size.Z),
+ })
+ p.client.SendPacket(p, &classic.SpawnPlayer {
+ PlayerId: -1,
+ Username: classic.PadString(p.name),
+ X: classic.FShort(p.state.Pos.X),
+ Y: classic.FShort(p.state.Pos.Y),
+ Z: classic.FShort(p.state.Pos.Z),
+ })
+ })
+}
+
+func (p *player) OnLevelError(from *level, message string, info levelInfo) {
+ p.SendMessage(from, "&cCannot join level: " + message)
+ fmt.Println(info)
+ if !info.IsSpawn {
+ p.Act(from, func() {
+ p.server.SendToSpawn(p, p)
+ })
+ } else {
+ p.Kick(from, "Error: " + message)
+ }
+}
+
+func (p *player) OnPlayer(
+ from *level, id levelPlayerId, name string, pos entityPos) {
+ p.Act(from, func() {
+ p.client.SendPacket(p, &classic.SpawnPlayer {
+ PlayerId: int8(id),
+ Username: classic.PadString(name),
+ X: classic.FShort(pos.X),
+ Y: classic.FShort(pos.Y),
+ Z: classic.FShort(pos.Z),
+ })
+ })
+}
+
+func (p *player) OnRemovePlayer(from *level, id levelPlayerId) {
+ p.Act(from, func() {
+ p.client.SendPacket(p, &classic.DespawnPlayer {int8(id)})
+ })
+}
+
+func (p *player) OnMovePlayer(
+ from *level, id levelPlayerId, pos entityPos, facing entityFacing) {
+ p.Act(from, func() {
+ p.client.SendPacket(p, &classic.SetPosFacing {
+ PlayerId: int8(id),
+ X: classic.FShort(pos.X),
+ Y: classic.FShort(pos.Y),
+ Z: classic.FShort(pos.Z),
+ Yaw: facing.Yaw, Pitch: facing.Pitch,
+ })
+ })
+}
+
+func (p *player) OnSetBlock(from *level, pos blockPos, block blockType) {
+ p.client.SendPacket(p, &classic.SetBlock {
+ X: int16(pos.X),
+ Y: int16(pos.Y),
+ Z: int16(pos.Z),
+ Block: byte(block),
+ })
+}
diff --git a/server/server.go b/server/server.go
new file mode 100644
index 0000000..13e06f6
--- /dev/null
+++ b/server/server.go
@@ -0,0 +1,396 @@
+package server
+
+import (
+ "os"
+ "net"
+ "log"
+ "fmt"
+ "time"
+ "binary"
+ "os/signal"
+ "git.citrons.xyz/metronode/classic"
+ "git.citrons.xyz/metronode/phony"
+)
+
+var SoftwareName = "Metronode"
+
+type ServerInfo struct {
+ Name string
+ Motd string
+}
+
+type Server struct {
+ phony.Inbox
+ worldState
+ info ServerInfo
+ clients map[*client]bool
+ players map[string]*player
+ levels map[levelId]*level
+ listener net.Listener
+ stopping bool
+ stopped chan struct{}
+}
+
+type worldState struct {
+ SpawnLevel levelId
+ SpawnPos entityPos
+}
+
+func NewServer(info ServerInfo) *Server {
+ s := &Server {
+ info: info,
+ clients: make(map[*client]bool),
+ players: make(map[string]*player),
+ levels: make(map[levelId]*level),
+ stopped: make(chan struct{}),
+ }
+ err := os.Mkdir("world", 0777)
+ if err == nil {
+ spawnLevel := s.newLevel(levelInfo {
+ Id: 0,
+ Size: blockPos {X: 256, Y: 256, Z: 256},
+ IsSpawn: true,
+ })
+ spawnLevel.generateFlat()
+ s.SpawnPos = entityPos {
+ 128*blockSize,
+ 128*blockSize + playerHeight,
+ 128*blockSize,
+ }
+ } else {
+ f, err := os.Open("world/world.bin")
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = binary.Read(f, binary.BigEndian, &s.worldState)
+ if err != nil {
+ log.Fatal("read world data: %s", err)
+ }
+ return s
+}
+
+func (s *Server) Serve(ln net.Listener) {
+ dataManager.errHand = s
+ s.Act(nil, func() {s.listener = ln})
+ go func() {
+ defer s.Stop(nil)
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ s.Act(nil, func() {
+ s.clients[newClient(s, s.info, conn)] = true
+ })
+ }
+ }()
+ var (
+ ping = time.Tick(10 * time.Second)
+ tick = time.Tick(time.Second / 20)
+ save = time.Tick(time.Minute)
+ sigterm = make(chan os.Signal)
+ )
+ signal.Notify(sigterm, os.Interrupt)
+ defer signal.Stop(sigterm)
+ for {
+ select {
+ case <-ping: s.SendPings()
+ case <-tick: s.Tick()
+ case <-save: s.Save(nil)
+ case <-sigterm: s.Stop(nil)
+ case <-s.stopped: return
+ }
+ }
+}
+
+func (s *Server) stop() {
+ if s.stopping {
+ return
+ }
+ log.Println("stopping the server. please wait...")
+ s.listener.Close()
+ s.stopping = true
+ var (
+ savedPlayers int
+ savedLevels int
+ )
+ checkSaved := func() {
+ if savedLevels >= len(s.levels) && savedPlayers >= len(s.players) {
+ close(s.stopped)
+ for client := range s.clients {
+ client.Disconnect(s, "Shutting down...")
+ }
+ }
+ }
+ for _, player := range s.players {
+ player.Save(s, func() {
+ savedPlayers++
+ checkSaved()
+ })
+ }
+ for _, level := range s.levels {
+ level.Save(s, func() {
+ savedLevels++
+ checkSaved()
+ })
+ }
+}
+
+func (s *Server) Stop(from phony.Actor) {
+ s.Act(from, s.stop)
+}
+
+func (s *Server) Save(from phony.Actor) {
+ s.Act(from, func() {
+ for _, player := range s.players {
+ player.Save(s, nil)
+ }
+ for _, level := range s.levels {
+ level.Save(s, nil)
+ }
+ })
+}
+
+func (s *Server) SendPings() {
+ s.Act(nil, func() {
+ for cl := range s.clients {
+ cl.SendPing(s)
+ }
+ })
+}
+
+func (s *Server) OnDisconnect(cl *client, username string, pl *player) {
+ s.Act(cl, func() {
+ delete(s.clients, cl)
+ if s.stopping {
+ return
+ }
+ if s.players[username] == pl {
+ delete(s.players, username)
+ }
+ if username == "" {
+ return
+ }
+ s.Broadcast(nil, fmt.Sprintf("&e%s has left", username))
+ })
+}
+
+func (s *Server) OnPlayerMessage(from *player, name string, message string) {
+ s.Act(from, func() {
+ for _, player := range s.players {
+ player.OnPlayerMessage(s, name, message)
+ }
+ })
+}
+
+func (s *Server) OnLoadError(from phony.Actor, err error) {
+ log.Printf("error loading world: %s", err)
+}
+
+func (s *Server) OnSaveError(from phony.Actor, err error) {
+ log.Printf("error saving world: %s", err)
+ s.Broadcast(from, fmt.Sprintf("&4Error saving world: %s", err))
+}
+
+func (s *Server) Tick() {
+}
+
+func (s *Server) newLevel(info levelInfo) *level {
+ l := newLevel(s, info)
+ s.levels[info.Id] = l
+ return l
+}
+
+func (s *Server) newPlayer(cl *client, name string) *player {
+ pl := newPlayer(s, cl, name)
+ s.players[name] = pl
+ return pl
+}
+
+func (s *Server) NewPlayer(
+ from phony.Actor, cl *client, name string, reply func(*player)) {
+ s.Act(from, func() {
+ s.Broadcast(nil, fmt.Sprintf("&e%s has joined", name))
+ if s.players[name] != nil {
+ s.players[name].Act(s, func() {
+ s.players[name].kick("Replaced by new connection")
+ s.Act(s.players[name], func() {
+ s.newPlayer(cl, name)
+ s.GetPlayer(from, name, reply)
+ })
+ })
+ } else {
+ s.newPlayer(cl, name)
+ s.GetPlayer(from, name, reply)
+ }
+ })
+}
+
+func (s *Server) getLevel(lvl levelId) *level {
+ if s.levels[lvl] == nil {
+ s.levels[lvl] = loadLevel(s, lvl, lvl == s.spawnLevel)
+ }
+ return s.levels[lvl]
+}
+
+func (s *Server) GetLevel(
+ from phony.Actor, lvl levelId, reply func(*level)) {
+ s.Act(from, func() {
+ if s.stopping {
+ return
+ }
+ l := s.getLevel(lvl)
+ from.Act(nil, func() {reply(l)})
+ })
+}
+
+func (s *Server) SendToSpawn(from phony.Actor, pl *player) {
+ s.Act(from, func() {
+ pl.ChangeLevel(s, s.spawnLevel, s.spawnPos)
+ })
+}
+
+func (s *Server) GetPlayer(
+ from phony.Actor, name string, reply func(*player)) {
+ s.Act(from, func() {
+ from.Act(nil, func() {reply(s.players[name])})
+ })
+}
+
+func (s *Server) Broadcast(from phony.Actor, message string) {
+ for _, player := range s.players {
+ player.SendMessage(s, message)
+ }
+}
+
+type client struct {
+ phony.Inbox
+ server *Server
+ conn net.Conn
+ username string
+ player *player
+}
+
+func newClient(server *Server, srvInfo ServerInfo, conn net.Conn) *client {
+ cl := &client {server: server}
+ cl.Act(nil, func() {
+ cl.performHandshake(conn, srvInfo)
+ if cl.conn != nil {
+ go cl.readPackets(cl.conn)
+ }
+ })
+ return cl
+}
+
+func (cl *client) performHandshake(conn net.Conn, srvInfo ServerInfo) {
+ cl.conn = conn
+ conn.SetDeadline(time.Now().Add(10 * time.Second))
+
+ packet, err := classic.SReadPacket(conn)
+ if cl.handleError(err) != nil {
+ return
+ }
+ switch pid := packet.(type) {
+ case *classic.PlayerId:
+ if pid.Version != 7 {
+ cl.disconnect(
+ "Please join on protocol version 7 (Minecraft Classic 0.30 / "+
+ "ClassiCube)",
+ )
+ }
+ cl.username = classic.UnpadString(pid.Username)
+ default:
+ cl.disconnect("Expected handshake")
+ return
+ }
+ err = classic.WritePacket(conn, &classic.ServerId {
+ Version: 7,
+ ServerName: classic.PadString(srvInfo.Name),
+ Motd: classic.PadString(srvInfo.Motd),
+ })
+ if cl.handleError(err) != nil {
+ return
+ }
+ cl.server.NewPlayer(cl, cl, cl.username, func(pl *player) {
+ cl.player = pl
+ })
+
+ conn.SetDeadline(time.Time{})
+}
+
+func (cl *client) readPackets(conn net.Conn) {
+ for {
+ packet, err := classic.SReadPacket(conn)
+ cl.Act(nil, func() {
+ if cl.handleError(err) != nil {
+ return
+ }
+ if cl.player != nil {
+ cl.player.OnPacket(cl, packet)
+ }
+ })
+ if err != nil {
+ return
+ }
+ }
+}
+
+func (cl *client) handleError(err error) error {
+ if err == nil {
+ return err
+ }
+ cl.disconnect(err.Error())
+ return err
+}
+
+func (cl *client) disconnect(reason string) {
+ if cl.player != nil {
+ cl.player.Kick(cl, reason)
+ cl.player = nil
+ }
+ if cl.conn != nil {
+ cl.server.OnDisconnect(cl, cl.username, cl.player)
+ log.Printf("disconnecting client (%s): %s", cl.username, reason)
+ cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ classic.WritePacket(cl.conn, &classic.DisconnectPlayer {
+ Reason: classic.PadString(reason),
+ })
+ cl.conn.Close()
+ cl.conn = nil
+ }
+}
+
+func (cl *client) SendPacket(from phony.Actor, packet classic.Packet) {
+ cl.Act(from, func() {
+ if cl.conn != nil {
+ cl.handleError(classic.WritePacket(cl.conn, packet))
+ }
+ })
+}
+
+func (cl *client) SendPackets(from phony.Actor, packets []classic.Packet) {
+ cl.Act(from, func() {
+ for _, packet := range packets {
+ if cl.conn == nil {
+ return
+ }
+ cl.handleError(classic.WritePacket(cl.conn, packet))
+ }
+ })
+}
+
+func (cl *client) SendPing(from phony.Actor) {
+ cl.Act(from, func() {
+ if cl.conn != nil {
+ cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
+ cl.handleError(classic.WritePacket(cl.conn, &classic.Ping{}))
+ }
+ })
+}
+
+func (cl *client) Disconnect(from phony.Actor, reason string) {
+ cl.Act(from, func() {
+ cl.disconnect(reason)
+ })
+}