A homes plugin with config and permissions
Plugin: Homes. Players save named homes and teleport back to them.
| Command | Does | Permission |
|---|---|---|
/sethome [name] |
saves the current position as name (default home) |
homes.set |
/home [name] |
teleports to a home, with a cooldown | homes.use |
/delhome <name> |
deletes a home | homes.set |
/homes |
lists your homes | homes.use |
Limit: MaxHomes per player from config; the permission homes.unlimited (default: operators) lifts it.
Manifest
Section titled “Manifest”{ "id": "homes", "name": "Homes", "version": "1.0.0", "api": "1.0", "main": "Homes.PluginMain", "authors": ["you"], "description": "Set and teleport to homes.", "permissions": [ { "node": "homes.use", "default": "true", "description": "Use /home and /homes" }, { "node": "homes.set", "default": "true", "description": "Use /sethome and /delhome" }, { "node": "homes.unlimited", "default": "op", "description": "No limit on the number of homes" } ]}The loader registers the manifest’s permissions with their defaults and descriptions before OnEnable runs, so a permissions plugin can list and grant them from the start.
Config and data types
Section titled “Config and data types”using System.Text.Json.Serialization;
namespace Homes;
/// <summary>plugins/homes/config.json: written with these defaults on first start, comments allowed.</summary>public sealed class HomesConfig{ public int MaxHomes { get; set; } = 3; public int CooldownSeconds { get; set; } = 5;}
/// <summary>One saved location.</summary>public sealed class Home{ public string World { get; set; } = "minecraft:overworld"; public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public float Yaw { get; set; } public float Pitch { get; set; }}
/// <summary>homes.json: player uuid to home name to home.</summary>public sealed class HomesData{ public Dictionary<string, Dictionary<string, Home>> Players { get; set; } = [];}
// System.Text.Json source generation: no reflection at run time[JsonSerializable(typeof(HomesConfig))][JsonSerializable(typeof(HomesData))][JsonSourceGenerationOptions(WriteIndented = true)]internal sealed partial class HomesJson : JsonSerializerContext { }The plugin
Section titled “The plugin”using System.Text.Json;
namespace Homes;
public sealed class PluginMain : Plugin{ private Config<HomesConfig> _config = null!; private HomesData _data = new(); private readonly Dictionary<Uuid, long> _lastTeleport = new(); // Environment.TickCount64 values (milliseconds)
protected override void OnEnable() { _config = Config.Load(HomesJson.Default.HomesConfig); // creates config.json with the defaults _data = LoadData();
Commands.Register(Command.Literal("sethome") .Permission("homes.set") .Executes(ctx => SetHome(ctx, "home")) .Then(Command.Argument("name", Arg.Word()) .Executes(ctx => SetHome(ctx, ctx.GetString("name")))), description: "Save your position as a home");
Commands.Register(Command.Literal("home") .Permission("homes.use") .Executes(ctx => GoHome(ctx, "home")) .Then(Command.Argument("name", Arg.Word()) .Suggests(OwnHomes) // tab completion of the player's own homes .Executes(ctx => GoHome(ctx, ctx.GetString("name")))), aliases: ["h"], description: "Teleport to a home");
Commands.Register(Command.Literal("delhome") .Permission("homes.set") .Then(Command.Argument("name", Arg.Word()) .Suggests(OwnHomes) .Executes(DeleteHome)), description: "Delete a home");
Commands.Register(Command.Literal("homes") .Permission("homes.use") .Executes(ListHomes), description: "List your homes");
// a player who leaves starts with a fresh cooldown next time Events.On((ref PlayerQuitEvent e) => _lastTeleport.Remove(e.Uuid)); Log.Info($"enabled, limit {_config.Value.MaxHomes} homes"); }
// ---- commands -----------------------------------------------------------------------------------------
private int SetHome(CommandContext ctx, string name) { Player player = ctx.RequirePlayer(); var homes = HomesOf(player, create: true)!; bool exists = homes.ContainsKey(name); int limit = _config.Value.MaxHomes; if (!exists && homes.Count >= limit && !player.HasPermission("homes.unlimited")) throw new CommandFailure($"You already have {homes.Count} homes (limit {limit}). Delete one with /delhome.");
Location at = player.Location; homes[name] = new Home { World = at.World.Key, X = at.X, Y = at.Y, Z = at.Z, Yaw = at.Yaw, Pitch = at.Pitch }; Save(); ctx.Reply(exists ? $"Home '{name}' moved." : $"Home '{name}' saved."); return homes.Count; }
private int GoHome(CommandContext ctx, string name) { Player player = ctx.RequirePlayer(); var homes = HomesOf(player, create: false); if (homes is null || !homes.TryGetValue(name, out var home)) throw new CommandFailure($"You have no home called '{name}'. Use /homes to list them.");
long now = Environment.TickCount64; long cooldownMs = _config.Value.CooldownSeconds * 1000L; if (_lastTeleport.TryGetValue(player.Uuid, out long last) && now - last < cooldownMs) throw new CommandFailure($"Wait {(cooldownMs - (now - last) + 999) / 1000} more seconds.");
World world = Server.GetWorld(home.World) ?? throw new CommandFailure($"The world '{home.World}' is not loaded."); // a Location carries its dimension: Teleport also moves the player to another world if (!player.Teleport(new Location(world, home.X, home.Y, home.Z, home.Yaw, home.Pitch))) throw new CommandFailure("The teleport was cancelled.");
_lastTeleport[player.Uuid] = now; ctx.Reply($"Welcome home, '{name}'."); return 1; }
private int DeleteHome(CommandContext ctx) { Player player = ctx.RequirePlayer(); string name = ctx.GetString("name"); var homes = HomesOf(player, create: false); if (homes is null || !homes.Remove(name)) throw new CommandFailure($"You have no home called '{name}'."); Save(); ctx.Reply($"Home '{name}' deleted."); return 1; }
private int ListHomes(CommandContext ctx) { Player player = ctx.RequirePlayer(); var homes = HomesOf(player, create: false); if (homes is null || homes.Count == 0) throw new CommandFailure("You have no homes. Use /sethome."); ctx.Reply($"Homes ({homes.Count}): {string.Join(", ", homes.Keys.Order())}"); return homes.Count; }
private IEnumerable<string> OwnHomes(CommandContext ctx) => ctx.Player is { } p && HomesOf(p, create: false) is { } homes ? homes.Keys.Order() : [];
// ---- data -----------------------------------------------------------------------------------------------
private Dictionary<string, Home>? HomesOf(Player player, bool create) { string key = player.Uuid.ToString(); if (_data.Players.TryGetValue(key, out var homes)) return homes; if (!create) return null; homes = new Dictionary<string, Home>(StringComparer.OrdinalIgnoreCase); _data.Players[key] = homes; return homes; }
private HomesData LoadData() { string? json = Config.ReadText("homes.json"); if (json is null) return new HomesData(); try { var data = JsonSerializer.Deserialize(json, HomesJson.Default.HomesData) ?? new HomesData(); // home names compare without regard to case: /home Base and /home base are the same home foreach (var key in data.Players.Keys.ToList()) data.Players[key] = new Dictionary<string, Home>(data.Players[key], StringComparer.OrdinalIgnoreCase); return data; } catch (JsonException e) { string path = Path.Combine(DataFolder, "homes.json"); File.Move(path, path + ".bad", overwrite: true); // keep the damaged file for the admin Log.Error("homes.json is damaged, starting with no homes (the file is kept as homes.json.bad)", e); return new HomesData(); } }
private void Save() => Config.WriteText("homes.json", JsonSerializer.Serialize(_data, HomesJson.Default.HomesData)); // atomic: temp file + rename}The command tree
Section titled “The command tree”This is the tree the four commands describe (nodes in italics are arguments; the orange chips are the permission each node requires). Switch to usage lines for the forms players see.
Homes commands
- sethomerunsneeds homes.setsaves as "home"
- <name>string: type=wordruns
- homerunsneeds homes.usealias: /h
- <name>string: type=wordrunssuggests your own homes
- delhomeneeds homes.set
- <name>string: type=wordrunssuggests your own homes
- homesrunsneeds homes.use
How it behaves
Section titled “How it behaves”- Permissions: with no permissions plugin, defaults apply: everyone can use
/homeand/sethome; only operators havehomes.unlimited. A permissions backend can grant or deny any node per player or group, and the commands appear or disappear in the client’s command tree accordingly. - Config: edit
plugins/homes/config.json. Call_config.Reload()(or set_config.Watch = trueinOnEnable) to pick up edits without a restart; a parse error keeps the previous values and logs the line. Values are read through_config.Valueat the time of use. - Hot reload: rebuild the plugin and the commands and permissions are registered again by the new
OnEnable; the homes are re-read from disk; the cooldown dictionary starts empty (it is not worth persisting). Nothing is lost becauseSave()writes through on every change. - Why milliseconds for the cooldown:
Environment.TickCount64measures wall-clock time, so the cooldown lasts the configured seconds even when the server runs behind. UseServer.CurrentTickinstead if the cooldown should follow the server’s tick rate. - Why
Dictionary<string, ...>keyed by the UUID string: JSON object keys are strings; the UUID is the stable identity of a player (names change).
- Add
/sethomeconfirmation as a boss bar or title: boss bars and titles. - Show homes in a sidebar: scoreboard sidebar.
- Expose
GetHomes(player)to other plugins: inter-plugin services.