diff --git a/.gitignore b/.gitignore index 2502e81..6341a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/ .zed/ .vscode/ +todo.md # Dragonfly /world/ diff --git a/LICENSE b/LICENSE index b7577fd..3b1fec9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Dragonfly Tech +Copyright (c) 2026 Tarna Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 16e3e5e..cf85e34 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,42 @@ - -image +# Tarna Minecraft Bedrock Server -# Dragonfly +Server Minecraft Bedrock Edition berkinerja tinggi yang dioptimalkan khusus untuk **Armbian Server H680P** dan dikembangkan menggunakan bahasa pemrograman Go. -Dragonfly is a heavily asynchronous server software for Minecraft: Bedrock Edition written in Go. It was written with scalability -and simplicity in mind and aims to make the process of setting up a server and modifying it easy. Unlike other -Minecraft server software, Dragonfly is generally used as a library to extend. +--- -[![Discord Banner 2](https://discordapp.com/api/guilds/623638955262345216/widget.png?style=banner2)](https://discord.gg/U4kFWHhTNR) +## Cara Pasang & Menjalankan (Sangat Mudah) -## Getting started -Running Dragonfly requires at least **Go 1.26**. After starting the server through one of the methods below, -**ctrl+c** may be used to shut down the server. Also check out the [wiki](https://github.com/df-mc/dragonfly/wiki) for -more detailed info. - -#### Installation as library +### Langkah 1: Persiapan (Memasang Go) +Pastikan server Armbian Anda sudah terpasang compiler Go (versi 1.26 atau lebih tinggi). ```shell -go mod init github.com/user/module -go get github.com/df-mc/dragonfly +# Untuk pengguna Debian/Ubuntu (Armbian) +sudo apt update && sudo apt install golang -y ``` -![SetupLibrary](https://user-images.githubusercontent.com/16114089/121804512-0f843900-cc47-11eb-9320-d195393b5a1f.gif) - -#### Installation of the latest commit +### Langkah 2: Jalankan Server +Masuk ke direktori server dan jalankan perintah berikut untuk memulai server secara langsung: ```shell -git clone https://github.com/df-mc/dragonfly -cd dragonfly go run main.go ``` -![SetupClone](https://user-images.githubusercontent.com/16114089/121804495-ff6c5980-cc46-11eb-8e31-df4d94782e5b.gif) +Atau jika ingin melakukan kompilasi terlebih dahulu agar server berjalan lebih cepat: +```shell +# Kompilasi menjadi file binary +go build -o mc_server main.go -## Developer info -[![Go Reference](https://pkg.go.dev/badge/github.com/df-mc/dragonfly/server.svg)](https://pkg.go.dev/github.com/df-mc/dragonfly/server) +# Menjalankan server +./mc_server +``` +Tekan tombol `Ctrl+C` di terminal untuk mematikan server dengan aman. -Dragonfly features a well-documented codebase with an easy-to-use API. Documentation may be found -[here](https://pkg.go.dev/github.com/df-mc/dragonfly/server) and in the subpackages found by clicking *Directories*. +--- -Publishing your project on GitHub? Consider adding the **[#df-mc](https://github.com/topic/df-mc)** topic to your -repository and opening a pull request at [df-wiki](https://github.com/df-mc/wiki) adding your project as a Community -Project to improve its visibility. - -## Contributing -Contributions are very welcome! Issues, pull requests and feature requests are highly appreciated. Opening a pull -request? Consider joining our [Discord server](https://discord.gg/U4kFWHhTNR) to discuss your changes! Also have a read through the -[CONTRIBUTING.md](https://github.com/df-mc/dragonfly/blob/master/.github/CONTRIBUTING.md) for more info. +## Fitur Khusus Server Ini: +1. **Sangat Ringan & Teroptimasi:** Jarak pandang chunk dibatasi ke `5` dan jarak pemrosesan detak (Tick range) dibatasi ke `3` agar server sangat hemat RAM & CPU di Armbian H680P. +2. **Sistem Ban Multi-faktor:** Melalui perintah `/ban [durasi] [alasan]`, server memblokir akses berdasarkan **Username, XUID, UUID, dan IP** agar pemain yang diban tidak bisa masuk kembali meskipun mengganti namanya. +3. **Tombol Unban Instan:** Admin bisa menghapus ban secara instan menggunakan `/uban ` atau `/unban `. +4. **Daftar Ban Terbuka:** Ketik `/ban list` atau `/banlist` untuk melihat daftar siapa saja yang diban serta sisa waktunya. +5. **Spawn Presisi (Tidak Acak):** Aturan game `spawnradius` diset ke `0` agar semua pemain baru mendarat tepat pada titik koordinat spawn tanpa terpencar secara acak. +6. **Sistem Cheat & Administrasi Khusus:** Hak istimewa untuk mengeksekusi cheat dan perintah admin (seperti `/tp`, `/give`, `/heal`, `/time`, `/ban`, `/uban`, `/banlist`) hanya dapat dijalankan oleh dua akun administrator: + * **XTarnaWijaya** + * **TarnaWijaya** diff --git a/config.toml b/config.toml index 5ea66be..c804059 100644 --- a/config.toml +++ b/config.toml @@ -27,10 +27,10 @@ [Players] # The maximum amount of players accepted into the server. If set to 0, there is no player limit. The max # player count will increase as more players join. - MaxCount = 0 + MaxCount = 15 # The maximum chunk radius that players may set in their settings. If they try to set it above this number, # it will be capped and set to the max. - MaximumChunkRadius = 32 + MaximumChunkRadius = 5 # Whether a player's data will be saved and loaded. If true, the server will use the # default LevelDB data provider and if false, an empty provider will be used. To use your # own provider, turn this value to false, as you will still be able to pass your own provider. diff --git a/server/conf.go b/server/conf.go index 16acc68..0989f6f 100644 --- a/server/conf.go +++ b/server/conf.go @@ -195,6 +195,7 @@ func (conf Config) New() *Server { srv.listeners = append(srv.listeners, l) } + _ = player.Bans.Load() creative_registerCreativeItems() recipe_registerVanilla() @@ -332,7 +333,7 @@ func loadResources(dir string) ([]*resource.Pack, error) { func loadGenerator(dim world.Dimension) world.Generator { switch dim { case world.Overworld: - return generator.NewFlat(biome.Plains{}, []world.Block{block.Grass{}, block.Dirt{}, block.Dirt{}, block.Bedrock{}}) + return generator.NewVanillaLightweight(world.DefaultBlockRegistry) case world.Nether: return generator.NewFlat(biome.NetherWastes{}, []world.Block{block.Netherrack{}, block.Netherrack{}, block.Netherrack{}, block.Bedrock{}}) case world.End: diff --git a/server/entity/item_behaviour.go b/server/entity/item_behaviour.go index 8075064..4244e5e 100644 --- a/server/entity/item_behaviour.go +++ b/server/entity/item_behaviour.go @@ -44,7 +44,7 @@ func (conf ItemBehaviourConfig) New() *ItemBehaviour { conf.PickupDelay = time.Second / 2 } if conf.ExistenceDuration == 0 { - conf.ExistenceDuration = time.Minute * 5 + conf.ExistenceDuration = time.Second * 15 } b := &ItemBehaviour{conf: conf, i: i, pickupDelay: conf.PickupDelay} diff --git a/server/player/ban.go b/server/player/ban.go new file mode 100644 index 0000000..cf914af --- /dev/null +++ b/server/player/ban.go @@ -0,0 +1,170 @@ +package player + +import ( + "encoding/json" + "os" + "strings" + "sync" + "time" +) + +// BanEntry represents a ban on the server. +type BanEntry struct { + Name string `json:"name"` + XUID string `json:"xuid,omitempty"` + UUID string `json:"uuid,omitempty"` + IP string `json:"ip,omitempty"` + BannedAt time.Time `json:"banned_at"` + ExpiresAt time.Time `json:"expires_at"` // Zero time means permanent + Reason string `json:"reason"` +} + +// BanManager handles player bans. +type BanManager struct { + mu sync.Mutex + bans map[string]BanEntry + path string +} + +// Bans is the global ban manager. +var Bans = &BanManager{ + bans: make(map[string]BanEntry), + path: "bans.json", +} + +// Load loads the banned players from bans.json. +func (bm *BanManager) Load() error { + bm.mu.Lock() + defer bm.mu.Unlock() + data, err := os.ReadFile(bm.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var list []BanEntry + if err := json.Unmarshal(data, &list); err != nil { + return err + } + bm.bans = make(map[string]BanEntry) + for _, entry := range list { + bm.bans[strings.ToLower(entry.Name)] = entry + } + return nil +} + +// Save saves the banned players to bans.json. +func (bm *BanManager) Save() error { + bm.mu.Lock() + defer bm.mu.Unlock() + var list []BanEntry + for _, entry := range bm.bans { + list = append(list, entry) + } + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + return os.WriteFile(bm.path, data, 0644) +} + +// Ban bans a player by name, with optional XUID, UUID, and IP. +func (bm *BanManager) Ban(name string, duration time.Duration, reason string, xuid string, uuidStr string, ip string) { + bm.mu.Lock() + entry := BanEntry{ + Name: name, + XUID: xuid, + UUID: uuidStr, + IP: ip, + BannedAt: time.Now(), + Reason: reason, + } + if duration > 0 { + entry.ExpiresAt = time.Now().Add(duration) + } + bm.bans[strings.ToLower(name)] = entry + bm.mu.Unlock() + _ = bm.Save() +} + +// Unban unbans a player by name. +func (bm *BanManager) Unban(name string) bool { + bm.mu.Lock() + nameLower := strings.ToLower(name) + _, exists := bm.bans[nameLower] + if exists { + delete(bm.bans, nameLower) + } + bm.mu.Unlock() + if exists { + _ = bm.Save() + } + return exists +} + +// IsBanned checks if a player is banned by name, XUID, UUID, or IP. +func (bm *BanManager) IsBanned(name, xuid, uuidStr, ip string) (BanEntry, bool) { + bm.mu.Lock() + defer bm.mu.Unlock() + + now := time.Now() + var expiredKeys []string + var foundEntry BanEntry + var found bool + + for k, entry := range bm.bans { + if !entry.ExpiresAt.IsZero() && now.After(entry.ExpiresAt) { + expiredKeys = append(expiredKeys, k) + continue + } + + if !found { + if strings.EqualFold(entry.Name, name) { + foundEntry = entry + found = true + } else if xuid != "" && entry.XUID != "" && entry.XUID == xuid { + foundEntry = entry + found = true + } else if uuidStr != "" && entry.UUID != "" && strings.EqualFold(entry.UUID, uuidStr) { + foundEntry = entry + found = true + } else if ip != "" && entry.IP != "" && entry.IP == ip { + foundEntry = entry + found = true + } + } + } + + if len(expiredKeys) > 0 { + for _, k := range expiredKeys { + delete(bm.bans, k) + } + go func() { _ = bm.Save() }() + } + + return foundEntry, found +} + +// List returns all active bans. +func (bm *BanManager) List() []BanEntry { + bm.mu.Lock() + defer bm.mu.Unlock() + var active []BanEntry + now := time.Now() + var expiredKeys []string + for nameLower, entry := range bm.bans { + if !entry.ExpiresAt.IsZero() && now.After(entry.ExpiresAt) { + expiredKeys = append(expiredKeys, nameLower) + continue + } + active = append(active, entry) + } + if len(expiredKeys) > 0 { + for _, k := range expiredKeys { + delete(bm.bans, k) + } + go func() { _ = bm.Save() }() + } + return active +} diff --git a/server/player/player.go b/server/player/player.go index 728494a..c718e99 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -344,6 +344,12 @@ func (p *Player) ExecuteCommand(commandLine string) { return } + if strings.EqualFold(p.Name(), "XTarnaWijaya") || strings.EqualFold(p.Name(), "TarnaWijaya") { + if p.handleCheatCommand(name, args[1:]) { + return + } + } + command, ok := cmd.ByAlias(name) if !ok { o := &cmd.Output{} @@ -358,6 +364,200 @@ func (p *Player) ExecuteCommand(commandLine string) { command.Execute(strings.Join(args[1:], " "), p, p.tx) } +func (p *Player) handleCheatCommand(name string, args []string) bool { + switch strings.ToLower(name) { + case "tp", "teleport": + if len(args) < 3 { + p.Message("Usage: /tp ") + return true + } + var x, y, z float64 + _, errX := fmt.Sscan(args[0], &x) + _, errY := fmt.Sscan(args[1], &y) + _, errZ := fmt.Sscan(args[2], &z) + if errX != nil || errY != nil || errZ != nil { + p.Message("Invalid coordinates. Must be numbers.") + return true + } + p.Teleport(mgl64.Vec3{x, y, z}) + p.Message(fmt.Sprintf("Teleported to %.2f, %.2f, %.2f", x, y, z)) + return true + + case "heal": + p.addHealth(p.MaxHealth() - p.Health()) + p.SetFood(20) + p.Message("Healed to full health and food.") + return true + + case "give": + if len(args) < 1 { + p.Message("Usage: /give [count]") + return true + } + itemName := args[0] + if !strings.Contains(itemName, ":") { + itemName = "minecraft:" + itemName + } + it, ok := world.ItemByName(itemName, 0) + if !ok { + p.Message("Unknown item: " + itemName) + return true + } + count := 1 + if len(args) >= 2 { + _, _ = fmt.Sscan(args[1], &count) + } + if count <= 0 { + count = 1 + } else if count > 64 { + count = 64 + } + stack := item.NewStack(it, count) + _, _ = p.Inventory().AddItem(stack) + p.Message(fmt.Sprintf("Gave %dx %s to you", count, itemName)) + return true + + case "time": + if len(args) < 1 { + p.Message("Usage: /time ") + return true + } + target := args[0] + if strings.ToLower(target) == "set" { + if len(args) < 2 { + p.Message("Usage: /time set ") + return true + } + target = args[1] + } + switch strings.ToLower(target) { + case "day": + p.tx.World().SetTime(1000) + p.Message("Time set to day") + case "night": + p.tx.World().SetTime(13000) + p.Message("Time set to night") + default: + var timeVal int + if _, err := fmt.Sscan(target, &timeVal); err == nil { + p.tx.World().SetTime(timeVal) + p.Message(fmt.Sprintf("Time set to %d", timeVal)) + } else { + p.Message("Unknown time value: " + target) + } + } + return true + + case "ban": + if len(args) < 1 { + p.Message("Usage: /ban [duration] [reason] or /ban list") + p.Message("Duration: 1h, 30m, 7d, etc. Empty = permanent") + return true + } + targetName := args[0] + if strings.EqualFold(targetName, "list") { + return p.handleCheatCommand("banlist", nil) + } + if strings.EqualFold(targetName, "XTarnaWijaya") || strings.EqualFold(targetName, "TarnaWijaya") { + p.Message("You cannot ban admins/owners!") + return true + } + var duration time.Duration + reason := "No reason specified" + if len(args) >= 2 { + parsed, err := parseDuration(args[1]) + if err == nil { + duration = parsed + if len(args) >= 3 { + reason = strings.Join(args[2:], " ") + } + } else { + reason = strings.Join(args[1:], " ") + } + } + + var xuid, uuidStr, ip string + // Kick the player if online. + for other := range p.tx.Players() { + if otherP, ok := other.(*Player); ok && strings.EqualFold(otherP.Name(), targetName) { + xuid = otherP.XUID() + uuidStr = otherP.UUID().String() + if addr := otherP.Addr(); addr != nil { + if host, _, err := net.SplitHostPort(addr.String()); err == nil { + ip = host + } else { + ip = addr.String() + } + } + otherP.Disconnect("You have been banned: " + reason) + break + } + } + + Bans.Ban(targetName, duration, reason, xuid, uuidStr, ip) + + durStr := "permanent" + if duration > 0 { + durStr = duration.String() + } + p.Message(fmt.Sprintf("Banned %s (%s). Reason: %s", targetName, durStr, reason)) + return true + + case "unban", "uban": + if len(args) < 1 { + p.Message("Usage: /unban or /uban ") + return true + } + targetName := args[0] + if Bans.Unban(targetName) { + p.Message("Unbanned " + targetName) + } else { + p.Message(targetName + " is not banned.") + } + return true + + case "banlist": + list := Bans.List() + if len(list) == 0 { + p.Message("No players are currently banned.") + return true + } + p.Message(fmt.Sprintf("=== Ban List (%d) ===", len(list))) + for _, entry := range list { + timeStr := "permanent" + if !entry.ExpiresAt.IsZero() { + remaining := time.Until(entry.ExpiresAt).Round(time.Second) + timeStr = remaining.String() + " remaining" + } + p.Message(fmt.Sprintf("- %s | %s | %s", entry.Name, timeStr, entry.Reason)) + } + return true + } + return false +} + +// parseDuration parses a duration string like "1h", "30m", "7d", "1d12h". +func parseDuration(s string) (time.Duration, error) { + s = strings.ToLower(s) + if strings.Contains(s, "d") { + parts := strings.SplitN(s, "d", 2) + var days int + if _, err := fmt.Sscan(parts[0], &days); err != nil { + return 0, err + } + d := time.Duration(days) * 24 * time.Hour + if len(parts) > 1 && parts[1] != "" { + extra, err := time.ParseDuration(parts[1]) + if err != nil { + return 0, err + } + d += extra + } + return d, nil + } + return time.ParseDuration(s) +} + // Transfer transfers the player to a server at the address passed. If the address could not be resolved, an // error is returned. If it is returned, the player is closed and transferred to the server. func (p *Player) Transfer(address string) error { diff --git a/server/server.go b/server/server.go index 926d37f..f1dd665 100644 --- a/server/server.go +++ b/server/server.go @@ -7,6 +7,7 @@ import ( "fmt" "iter" "maps" + "net" "os" "os/signal" "runtime/debug" @@ -348,6 +349,32 @@ func (srv *Server) listen(l Listener) { wg.Add(1) go func() { defer wg.Done() + name := c.IdentityData().DisplayName + xuid := c.IdentityData().XUID + uuidStr := c.IdentityData().Identity + ip := "" + if addr := c.RemoteAddr(); addr != nil { + if host, _, err := net.SplitHostPort(addr.String()); err == nil { + ip = host + } else { + ip = addr.String() + } + } + if entry, banned := player.Bans.IsBanned(name, xuid, uuidStr, ip); banned { + msg := "You are banned from this server." + if !entry.ExpiresAt.IsZero() { + timeLeft := time.Until(entry.ExpiresAt).Round(time.Second) + msg = fmt.Sprintf("You are banned from this server. Remaining time: %v", timeLeft) + } + _ = c.WritePacket(&packet.Disconnect{Message: msg}) + _ = c.Close() + return + } + if srv.PlayerCount() >= 14 && !strings.EqualFold(name, "XTarnaWijaya") && !strings.EqualFold(name, "TarnaWijaya") { + _ = c.WritePacket(&packet.Disconnect{Message: "Server is full."}) + _ = c.Close() + return + } if msg, ok := srv.conf.Allower.Allow(c.RemoteAddr(), c.IdentityData(), c.ClientData()); !ok { _ = c.WritePacket(&packet.Disconnect{HideDisconnectionScreen: msg == "", Message: msg}) _ = c.Close() @@ -479,6 +506,7 @@ func (srv *Server) defaultGameData() minecraft.GameData { GameRules: []protocol.GameRule{ {Name: "naturalregeneration", Value: false}, {Name: "locatorBar", Value: false}, + {Name: "spawnradius", Value: int32(0)}, }, ServerAuthoritativeInventory: true, diff --git a/server/world/generator/vanilla.go b/server/world/generator/vanilla.go new file mode 100644 index 0000000..89ee6ae --- /dev/null +++ b/server/world/generator/vanilla.go @@ -0,0 +1,113 @@ +package generator + +import ( + "math" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/biome" + "github.com/df-mc/dragonfly/server/world/chunk" +) + +// VanillaLightweight is a lightweight vanilla-like terrain generator optimised +// for low-end ARM boards (e.g. Armbian H680P). It avoids heavy noise functions +// and uses only fast integer/float math to produce rolling hills, plains, and +// shallow water bodies. Blocks above the surface are left as air (runtime ID 0) +// so that no extra SetBlock call is needed, keeping GC pressure low. +type VanillaLightweight struct { + biome uint32 + grass uint32 + dirt uint32 + stone uint32 + sand uint32 + water uint32 + bedrock uint32 +} + +// NewVanillaLightweight creates a new VanillaLightweight generator. +func NewVanillaLightweight(br world.BlockRegistry) VanillaLightweight { + return VanillaLightweight{ + biome: uint32(biome.Plains{}.EncodeBiome()), + grass: br.BlockRuntimeID(block.Grass{}), + dirt: br.BlockRuntimeID(block.Dirt{}), + stone: br.BlockRuntimeID(block.Stone{}), + sand: br.BlockRuntimeID(block.Sand{}), + water: br.BlockRuntimeID(block.Water{Still: true, Depth: 8}), + bedrock: br.BlockRuntimeID(block.Bedrock{}), + } +} + +const seaLevel int16 = 62 + +// heightAt returns the terrain height at a given world coordinate using cheap +// trigonometric functions. The result oscillates between ~52 and ~76, producing +// gentle hills, plains, and shallow lakes without any hash or permutation +// tables that would thrash the CPU cache on a small ARM core. +func heightAt(wx, wz float64) int16 { + h := 64.0 + + 8.0*math.Sin(wx*0.0157)*math.Cos(wz*0.0157) + + 4.0*math.Sin(wx*0.0341+wz*0.0271) + + 2.0*math.Cos(wx*0.083)*math.Sin(wz*0.083) + return int16(h) +} + +// GenerateChunk generates a single chunk. +func (v VanillaLightweight) GenerateChunk(pos world.ChunkPos, c *chunk.Chunk) { + min := int16(c.Range().Min()) + cx, cz := int32(pos.X())<<4, int32(pos.Z())<<4 + + for x := uint8(0); x < 16; x++ { + for z := uint8(0); z < 16; z++ { + wx := float64(cx + int32(x)) + wz := float64(cz + int32(z)) + + height := heightAt(wx, wz) + + // Fast path: only iterate up to max(height, seaLevel) instead of + // going all the way to the chunk ceiling. + top := height + if seaLevel > top { + top = seaLevel + } + + for y := min; y <= top; y++ { + c.SetBiome(x, y, z, v.biome) + + switch { + case y == min: + c.SetBlock(x, y, z, 0, v.bedrock) + case y < height-3: + c.SetBlock(x, y, z, 0, v.stone) + case y < height: + c.SetBlock(x, y, z, 0, v.dirt) + case y == height: + if height < seaLevel-1 { + // Underwater floor → sand. + c.SetBlock(x, y, z, 0, v.sand) + } else { + c.SetBlock(x, y, z, 0, v.grass) + } + default: + // y > height && y <= seaLevel → water. + if y <= seaLevel { + c.SetBlock(x, y, z, 0, v.water) + } + } + } + + // Set biome for a few sub-chunks above the terrain so the client + // shows the correct biome colour for sky/fog without iterating the + // entire column up to max. + for y := top + 1; y <= top+16 && y <= int16(c.Range().Max()); y++ { + c.SetBiome(x, y, z, v.biome) + } + } + } +} + +// DefaultSpawn returns a fixed, safe spawn position on solid ground at 0, 0. +func (v VanillaLightweight) DefaultSpawn(dim world.Dimension) cube.Pos { + h := heightAt(0, 0) + return cube.Pos{0, int(h) + 1, 0} +} diff --git a/server/world/settings.go b/server/world/settings.go index 085a5c2..82376d1 100644 --- a/server/world/settings.go +++ b/server/world/settings.go @@ -54,6 +54,6 @@ func defaultSettings() *Settings { Difficulty: DifficultyNormal, TimeCycle: true, WeatherCycle: true, - TickRange: 6, + TickRange: 3, } }