Compare commits

..
57 Commits
Author SHA1 Message Date
anth64 636a7dee98 feat: filter buffer data sent to visualizer clients by view distance 2026-07-31 07:38:56 +02:00
anth64 1e4400555c chore: bump version to 0.2.0 2026-07-31 07:18:56 +02:00
anth64 0dfcc5396a feat: add client-visualizer network endpoint for buffer geometry 2026-07-30 23:14:06 +02:00
anth64 c110ee2604 fix: normalize claim geometry before growing buffer (raw start/end marks aren't min/max ordered) 2026-07-27 07:40:12 +02:00
anth64 baf3ccbd93 feat: enforce buffer-zone non-overlap on /claimlink link 2026-07-26 15:30:35 +02:00
anth64 3cf6db1d2a fix: clear PendingLoads on /land claim new to match vanilla's unconditional reset 2026-07-26 14:36:37 +02:00
anth64 12219e6ef5 feat: add ClaimLinkConfig with configurable buffer size 2026-07-26 12:07:46 +02:00
anth64 0e0e4e0f74 chore: bump version to 0.1.0 2026-07-26 11:28:08 +02:00
anth64 154e7b7848 chore: bump version to 0.0.7 2026-07-26 11:25:27 +02:00
anth64 9e37ec11cf feat: implement admin delete/unlink/kick, fix admin unlink claim arg type 2026-07-26 11:23:21 +02:00
anth64 4d98a115ed feat: infer claim link group from chat context when omitted; drop redundant admin info 2026-07-26 11:21:13 +02:00
anth64 99238c4744 feat: hook group leave/kick to proactively cancel pending actions with a reason 2026-07-26 10:33:46 +02:00
anth64 1c079b7e44 fix: cancel pending actions on group disband even if not yet a claim link 2026-07-26 09:08:08 +02:00
anth64 b7ca9fa53b fix: track groupId per pending action for precise cleanup on delete/disband; split group hook into its own listener; self-heal orphaned registry entries in list 2026-07-26 08:51:54 +02:00
anth64 c55787ce40 feat: react to /group confirmdisband and recheck live membership on link confirm 2026-07-23 07:57:39 +02:00
anth64 b10d3a0f55 fix: cancel old owner's pending action on ownership transfer 2026-07-21 20:29:32 +02:00
anth64 b5ceba442a fix: cancel kicked player's pending action to stop stale link re-add 2026-07-21 20:25:21 +02:00
anth64 d248b772dc fix: split adminfree (by player name) from adminfreehere (by position) 2026-07-21 19:36:48 +02:00
anth64 c8fc86069b feat: gate claim link info detail by membership/admin, count members with no claims 2026-07-20 23:36:03 +02:00
anth64 2e4cc25b20 fix: guard claim-link entries against stale indices and dead groups 2026-07-20 23:28:37 +02:00
anth64 fc7e601f5b refactor: expose TryResolveOwnedClaim for future external mod consumption 2026-07-19 20:43:05 +02:00
anth64 a2e4a68f9e refactor: rebuild claim link registry around live index tracking, drop ClaimLink type 2026-07-19 20:31:10 +02:00
anth64 741d283f38 refactor: clear pending actions in claim link command instead of land claim listener 2026-07-19 10:49:11 +02:00
anth64 9fcf6f1da5 fix: resolve claim on load 2026-07-19 10:48:41 +02:00
anth64 f699cd6d54 chore: log adminfree phase and claims found at caller position 2026-07-18 11:55:24 +02:00
anth64 94b6723456 feat: sanity-check claim save/free against tracked ordering state 2026-07-18 11:47:52 +02:00
anth64 de2026d239 refactor: merge land command Before/After into shared phase-aware dispatch 2026-07-18 11:46:32 +02:00
anth64 fffc046126 refactor: normalize claim link membership into flat entry storage 2026-07-15 18:46:52 +02:00
anth64 eaa79f8f52 fix: transferownership correctly sets owner level and demotes old owner to op, works for offline players, notifies members 2026-07-14 22:52:32 +02:00
anth64 1c0a5f88a1 refactor: remove "claims" text from list of claims in FormatInfo. 2026-07-14 22:21:07 +02:00
anth64 d4ef3eba06 fix: show player name in require player 2026-07-14 22:17:00 +02:00
anth64 32dbd4ad91 feat: implement claimlink admin transferownership 2026-07-13 07:56:37 +02:00
anth64 41a2f1a09d feat: implement claimlink transferownership with debug logging 2026-07-13 07:50:50 +02:00
anth64 bee6b6434a fix: RequireOpOrOwner allows OwnerUID holder regardless of membership level 2026-07-13 07:40:24 +02:00
anth64 979f02e7f8 feat: open info to any player, add claimlink list, bump to 0.0.6
Modified command messages.
2026-07-12 21:19:24 +02:00
anth64 acbc45483e feat: implement claimlink delete 2026-07-12 19:54:13 +02:00
anth64 4e97e25b38 refactor: unlink no longer needs a groupname arg
A claim can only belong to one claim link at a time, so it resolves
via the new Registry.FindLinkContaining(playerUid, claimIndex).
2026-07-12 19:52:23 +02:00
anth64 1803aaaa37 fix: clear kicked player's pending action on kick 2026-07-12 19:46:08 +02:00
anth64 c035bf1a73 refactor: replace pending-link buffer with generic pending-action confirm/cancel
new/link/unlink/kick now stage a validated action and require confirm
to execute, instead of new alone gating a multi-edit draft. Bump to
0.0.5.
2026-07-12 19:43:00 +02:00
anth64 565f91408b feat: implement claimlink info, bump to 0.0.4 2026-07-12 19:27:31 +02:00
anth64 e7118e259b feat: implement claimlink kick 2026-07-12 19:23:54 +02:00
anth64 65e00e2f57 feat: stage claimlink new behind confirm/cancel pending state 2026-07-12 19:09:40 +02:00
anth64 c7e3f97a85 feat: implement claimlink unlink 2026-07-12 19:06:43 +02:00
anth64 0914d55d9a refactor: extract guard-clause helpers for group/link resolution 2026-07-12 19:05:27 +02:00
anth64 ac79d2ac94 feat: implement claimlink link 2026-07-12 19:00:10 +02:00
anth64 3b6a44a779 chore: bump version to 0.0.3 2026-07-12 18:50:20 +02:00
anth64 deafd98f40 feat: implement claimlink new 2026-07-12 18:49:54 +02:00
anth64 3b63c5b4af refactor: group member's claim indices into one list per player 2026-07-12 18:41:51 +02:00
anth64 cf17697a5e feat: add member claims to the ClaimLink model
Each ClaimLinkMember stores an owner player uid plus that player's
local claim index (resolved fresh at use, not trusted as stable) so
a claim link can reference existing vanilla claims without copying
their data. Buffer claim is intentionally not referenced here; it
resolves on demand via group ownership.
2026-07-12 18:38:09 +02:00
anth64 02c9a22bf1 feat: add ClaimLink persistence via savegame-backed registry
Adds a ClaimLink data model and ClaimLinkRegistry that loads from
and writes to the savegame's keyed data store (protobuf-net, via
WorldManager.SaveGame), so claim link state survives restarts
without touching vanilla claim data. Also silences nullable
warnings on statics that are guaranteed set by StartServerSide
before any use, and on TryGetValue out-params guarded by the if.
2026-07-12 18:31:21 +02:00
anth64 a470456dcc refactor: revert claimlink admin info to a plain stub 2026-07-12 16:23:34 +02:00
anth64 1baa20022b feat: stub out full /land command surface as hook points
Table-drives dispatch for every vanilla /land subcommand (free,
info, list, adminfree, claim's new/grant/revoke/grow/shrink/etc.)
so each has a named hook to fill in.
2026-07-12 16:16:25 +02:00
anth64 5c7fa58ea2 feat: hook /land claim load/cancel and dump claim info via admin info
Intercepts vanilla /land claim load|cancel to track a per-player
pending claim selection, and wires LandClaimAPI/Logger through
ModSystem so admin info can dump full claim state for debugging.
2026-07-12 16:13:06 +02:00
anth64 9206ecd6a9 refactor: add aliases for claim link and admin sub commands 2026-07-12 09:43:31 +02:00
anth64 7d5deb5bdf refactor: drive subcommand registration from a data table 2026-07-11 17:55:32 +02:00
anth64 28a89056e6 feat: add admin management surface 2026-07-01 20:04:00 +02:00
anth64 72b59c93ed feat: build /claimlink command tree stubbed 2026-07-01 19:12:10 +02:00
11 changed files with 1922 additions and 34 deletions
+27
View File
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using ProtoBuf;
namespace ClaimLink;
[ProtoContract]
public class ClaimLinkSaveData
{
[ProtoMember(1)]
public List<int> Links = new();
[ProtoMember(2)]
public List<ClaimLinkEntrySaveData> Entries = new();
}
[ProtoContract]
public class ClaimLinkEntrySaveData
{
[ProtoMember(1)]
public string OwnerPlayerUid = "";
[ProtoMember(2)]
public int GroupId;
[ProtoMember(3)]
public List<int> ClaimIndicies = new();
}
+4
View File
@@ -16,6 +16,10 @@
<HintPath>$(COMMANDHOOK)/CommandHook.dll</HintPath> <HintPath>$(COMMANDHOOK)/CommandHook.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="protobuf-net">
<HintPath>$(VINTAGE_STORY)/Lib/protobuf-net.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+140
View File
@@ -0,0 +1,140 @@
using System.Collections.Generic;
using Vintagestory.API.Common;
using Vintagestory.API.MathTools;
namespace ClaimLink;
internal static class ClaimLinkBuffer
{
internal static List<Cuboidi> GetBuffer(int groupId)
{
List<Cuboidi> cuboids = new();
foreach (string uid in ClaimLinkModSystem.Registry.MemberUidsForGroup(groupId))
{
foreach (
int claimIndex in ClaimLinkModSystem.Registry.ClaimsForPlayerInGroup(uid, groupId)
)
{
if (
!ClaimLinkModSystem.TryResolveOwnedClaim(uid, claimIndex, out LandClaim? claim)
|| claim == null
)
continue;
foreach (Cuboidi area in claim.Areas)
cuboids.Add(GrowOne(area));
}
}
return cuboids;
}
internal static bool TryFindOverlappingClaimLink(
List<Cuboidi> areas,
ISet<int>? excludeGroupIds,
out int violatingGroupId
)
{
foreach (int groupId in ClaimLinkModSystem.Registry.All)
{
if (excludeGroupIds != null && excludeGroupIds.Contains(groupId))
continue;
foreach (Cuboidi bufferCuboid in GetBuffer(groupId))
foreach (Cuboidi area in areas)
if (area.Intersects(bufferCuboid))
{
violatingGroupId = groupId;
return true;
}
}
violatingGroupId = -1;
return false;
}
internal static bool TryFindOverlappingClaim(
List<Cuboidi> areas,
string excludeOwnerUid,
ISet<int>? excludeGroupIds,
out LandClaim? violatingClaim
)
{
HashSet<LandClaim> excludedClaims = new();
if (excludeGroupIds != null)
foreach (int groupId in excludeGroupIds)
foreach (string uid in ClaimLinkModSystem.Registry.MemberUidsForGroup(groupId))
foreach (
int claimIndex in ClaimLinkModSystem.Registry.ClaimsForPlayerInGroup(
uid,
groupId
)
)
if (
ClaimLinkModSystem.TryResolveOwnedClaim(
uid,
claimIndex,
out LandClaim? excludedClaim
)
&& excludedClaim != null
)
excludedClaims.Add(excludedClaim);
foreach (LandClaim claim in ClaimLinkModSystem.LandClaimAPI.All)
{
if (claim.OwnedByPlayerUid == excludeOwnerUid)
continue;
if (excludedClaims.Contains(claim))
continue;
foreach (Cuboidi claimArea in claim.Areas)
foreach (Cuboidi area in areas)
if (area.Intersects(claimArea))
{
violatingClaim = claim;
return true;
}
}
violatingClaim = null;
return false;
}
internal static ISet<int> MemberClaimLinkIds(string uid)
{
HashSet<int> groupIds = new();
var memberships = ClaimLinkModSystem.PlayerData.GetPlayerDataByUid(uid)?.PlayerGroupMemberships;
if (memberships == null)
return groupIds;
foreach (int groupId in memberships.Keys)
if (ClaimLinkModSystem.Registry.Exists(groupId))
groupIds.Add(groupId);
return groupIds;
}
internal static List<Cuboidi> GrowAreas(List<Cuboidi> areas)
{
List<Cuboidi> grown = new();
foreach (Cuboidi area in areas)
grown.Add(GrowOne(area));
return grown;
}
private static Cuboidi GrowOne(Cuboidi area)
{
Cuboidi normalized = new Cuboidi(
area.MinX,
area.MinY,
area.MinZ,
area.MaxX,
area.MaxY,
area.MaxZ
);
int bufferSize = ClaimLinkModSystem.Config.BufferSize;
return normalized.GrowBy(bufferSize, bufferSize, bufferSize);
}
}
+933
View File
@@ -0,0 +1,933 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Vintagestory.API.Common;
using Vintagestory.API.Config;
using Vintagestory.API.MathTools;
using Vintagestory.API.Server;
namespace ClaimLink;
public static class ClaimLinkChatCommand
{
private readonly struct CommandSpec
{
public readonly string[] Names;
public readonly string Description;
public readonly ICommandArgumentParser[] Args;
public readonly bool IsPlayerOnly;
public readonly OnCommandDelegate Handler;
public CommandSpec(
string[] names,
string description,
ICommandArgumentParser[] args,
bool isPlayerOnly,
OnCommandDelegate handler
)
{
Names = names;
Description = description;
Args = args;
IsPlayerOnly = isPlayerOnly;
Handler = handler;
}
}
private static void BuildSubCommand(IChatCommand parent, CommandSpec spec)
{
var sub =
spec.Names.Length > 1
? parent.BeginSubCommands(spec.Names)
: parent.BeginSubCommand(spec.Names[0]);
sub.WithDescription(spec.Description);
if (spec.Args.Length > 0)
sub.WithArgs(spec.Args);
if (spec.IsPlayerOnly)
sub.RequiresPlayer();
else
sub.RequiresPrivilege(Privilege.controlserver);
sub.HandleWith(spec.Handler).EndSubCommand();
}
public static void Register(ICoreServerAPI api)
{
var p = api.ChatCommands.Parsers;
var root = api
.ChatCommands.Create("claimlink")
.WithAlias("clink", "claiml", "cl")
.WithDescription("Link vanilla land claims together via group.")
.RequiresPrivilege(Privilege.chat);
CommandSpec[] playerCommands =
{
new(
new[] { "new", "n" },
"Promote a group you own to a claim link.",
new ICommandArgumentParser[] { p.OptionalWord("groupname") },
true,
New
),
new(
new[] { "link", "l" },
"Link a claim you own to a claim link.",
new ICommandArgumentParser[]
{
p.IntRange("claim", 0, 999),
p.OptionalWord("groupname"),
},
true,
Link
),
new(
new[] { "confirm", "c" },
"Confirm pending action.",
Array.Empty<ICommandArgumentParser>(),
true,
Confirm
),
new(
new[] { "cancel" },
"Cancel pending action.",
Array.Empty<ICommandArgumentParser>(),
true,
Cancel
),
new(
new[] { "unlink", "ul" },
"Remove a claim you own from its claim link.",
new ICommandArgumentParser[] { p.IntRange("claim", 0, 999) },
true,
Unlink
),
new(
new[] { "kick" },
"Force-unlink all of a player's claims from the claim link.",
new ICommandArgumentParser[] { p.Word("groupname"), p.OptionalWord("playername") },
true,
Kick
),
new(
new[] { "delete" },
"Delete a claim link.",
new ICommandArgumentParser[] { p.OptionalWord("groupname") },
true,
Delete
),
new(
new[] { "transferownership", "transfer", "to" },
"Transfer ownership of the claim link to another player.",
new ICommandArgumentParser[] { p.Word("groupname"), p.OptionalWord("playername") },
true,
TransferOwnership
),
new(
new[] { "info", "i" },
"Show a claim link's members and linked claims.",
new ICommandArgumentParser[] { p.OptionalWord("groupname") },
true,
Info
),
new(
new[] { "list", "ls" },
"List all claim links.",
Array.Empty<ICommandArgumentParser>(),
true,
List
),
};
foreach (var spec in playerCommands)
BuildSubCommand(root, spec);
var admin = root.BeginSubCommands("admin", "a")
.WithDescription("Admin commands for claim link.")
.RequiresPrivilege(Privilege.controlserver);
CommandSpec[] adminCommands =
{
new(
new[] { "delete", "del" },
"Delete a claim link.",
new ICommandArgumentParser[] { p.OptionalWord("groupname") },
false,
AdminDelete
),
new(
new[] { "unlink" },
"Unlink a claim.",
new ICommandArgumentParser[] { p.Word("playername"), p.IntRange("claim", 0, 999) },
false,
AdminUnlink
),
new(
new[] { "kick" },
"Force-unlink all of a player's claims.",
new ICommandArgumentParser[] { p.Word("groupname"), p.OptionalWord("playername") },
false,
AdminKick
),
new(
new[] { "transferownership", "transfer", "to" },
"Transfer ownership of any claim link to another player.",
new ICommandArgumentParser[]
{
p.PlayerUids("playername"),
p.OptionalWord("groupname"),
},
false,
AdminTransferOwnership
),
};
foreach (var spec in adminCommands)
BuildSubCommand(admin, spec);
admin.EndSubCommand();
}
internal static readonly Dictionary<
string,
(int GroupId, Func<TextCommandResult> Action)
> PendingActions = new();
internal static readonly Dictionary<string, string> LastCancelReason = new();
private static TextCommandResult Stage(
string playerUid,
int groupId,
string prompt,
Func<TextCommandResult> action
)
{
PendingActions[playerUid] = (groupId, action);
LastCancelReason.Remove(playerUid);
return TextCommandResult.Success(
$"{prompt} Use /claimlink confirm to proceed, or /claimlink cancel to cancel."
);
}
internal static void RemovePendingActionsForGroup(int groupId, string reason)
{
List<string>? toRemove = null;
foreach (var (uid, entry) in PendingActions)
if (entry.GroupId == groupId)
(toRemove ??= new()).Add(uid);
if (toRemove == null)
return;
foreach (string uid in toRemove)
{
PendingActions.Remove(uid);
LastCancelReason[uid] = reason;
}
}
internal static void CancelPendingAction(string uid, int groupId, string reason)
{
if (!PendingActions.TryGetValue(uid, out var pending) || pending.GroupId != groupId)
return;
PendingActions.Remove(uid);
LastCancelReason[uid] = reason;
}
private static TextCommandResult? TryResolveGroup(string groupName, out PlayerGroup group)
{
group = ClaimLinkModSystem.Groups.GetPlayerGroupByName(groupName)!;
return group == null
? TextCommandResult.Error($"No group named '{groupName}' exists.")
: null;
}
private static TextCommandResult? TryResolveGroupArg(
TextCommandCallingArgs args,
int argIndex,
out PlayerGroup group
)
{
if (!args.Parsers[argIndex].IsMissing)
return TryResolveGroup((string)args[argIndex], out group);
int chatGroupId = args.Caller.FromChatGroupId;
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(chatGroupId, out group!))
return TextCommandResult.Error(
"No group specified and you are not sending this from a group chat channel."
);
return null;
}
private static TextCommandResult? TryResolveClaimLink(PlayerGroup group)
{
return !ClaimLinkModSystem.Registry.Exists(group.Uid)
? TextCommandResult.Error($"'{group.Name}' is not a claim link.")
: null;
}
private static TextCommandResult? RequireMember(IPlayer player, PlayerGroup group)
{
return player.GetGroup(group.Uid) == null
? TextCommandResult.Error($"'{player.PlayerName}' is not a member of '{group.Name}'.")
: null;
}
private static TextCommandResult? RequireOwner(IPlayer player, PlayerGroup group)
{
return group.OwnerUID != player.PlayerUID
? TextCommandResult.Error($"You do not own '{group.Name}'.")
: null;
}
private static TextCommandResult? RequireOpOrOwner(IPlayer player, PlayerGroup group)
{
if (group.OwnerUID == player.PlayerUID)
return null;
PlayerGroupMembership? membership = player.GetGroup(group.Uid);
return membership == null || membership.Level < EnumPlayerGroupMemberShip.Op
? TextCommandResult.Error($"You must be an operator of '{group.Name}' to do that.")
: null;
}
public static TextCommandResult New(TextCommandCallingArgs args)
{
string playerUid = args.Caller.Player.PlayerUID;
TextCommandResult? err = TryResolveGroupArg(args, 0, out PlayerGroup group);
if (err != null)
return err;
err = RequireOwner(args.Caller.Player, group);
if (err != null)
return err;
if (ClaimLinkModSystem.Registry.Exists(group.Uid))
return TextCommandResult.Error($"'{group.Name}' is already a claim link.");
int groupId = group.Uid;
string groupName = group.Name;
return Stage(
playerUid,
groupId,
$"'{groupName}' will become a claim link.",
() =>
{
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.ContainsKey(groupId))
return TextCommandResult.Error($"'{groupName}' no longer exists.");
ClaimLinkModSystem.Registry.Add(groupId);
return TextCommandResult.Success($"'{groupName}' is now a claim link.");
}
);
}
public static TextCommandResult Confirm(TextCommandCallingArgs args)
{
string playerUid = args.Caller.Player.PlayerUID;
if (!PendingActions.TryGetValue(playerUid, out var pending))
{
if (LastCancelReason.Remove(playerUid, out string? reason))
return TextCommandResult.Error($"You do not have a pending action ({reason}).");
return TextCommandResult.Error("You do not have a pending action.");
}
PendingActions.Remove(playerUid);
LastCancelReason.Remove(playerUid);
return pending.Action();
}
public static TextCommandResult Cancel(TextCommandCallingArgs args)
{
string playerUid = args.Caller.Player.PlayerUID;
if (!PendingActions.Remove(playerUid))
return TextCommandResult.Error("You do not have a pending action.");
return TextCommandResult.Success("Pending action cancelled.");
}
public static TextCommandResult Link(TextCommandCallingArgs args)
{
int claimIndex = (int)args[0];
TextCommandResult? err = TryResolveGroupArg(args, 1, out PlayerGroup group);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
IPlayer player = args.Caller.Player;
err = RequireMember(player, group);
if (err != null)
return err;
string playerUid = player.PlayerUID;
int groupId = group.Uid;
string groupName = group.Name;
if (
!ClaimLinkModSystem.TryResolveOwnedClaim(playerUid, claimIndex, out LandClaim? claim)
|| claim == null
)
return TextCommandResult.Error($"You don't have a claim at index {claimIndex}.");
if (ClaimLinkModSystem.Registry.IsClaimLinked(playerUid, claimIndex))
return TextCommandResult.Error("That claim is already part of a claim link.");
List<Cuboidi> grownAreas = ClaimLinkBuffer.GrowAreas(claim.Areas);
ISet<int> excludeGroupIds = new HashSet<int> { groupId };
if (
ClaimLinkBuffer.TryFindOverlappingClaimLink(
grownAreas,
excludeGroupIds,
out int violatingGroupId
)
)
{
string violatingName = ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(
violatingGroupId,
out PlayerGroup? violatingGroup
)
? violatingGroup!.Name
: "another claim link";
return TextCommandResult.Error(
$"Cannot link that claim, it overlaps the protected buffer of '{violatingName}'."
);
}
if (
ClaimLinkBuffer.TryFindOverlappingClaim(
grownAreas,
playerUid,
excludeGroupIds,
out LandClaim? violatingClaim
)
)
{
return TextCommandResult.Error(
$"Cannot link that claim, its buffer would overlap the claim owned by {violatingClaim!.LastKnownOwnerName}."
);
}
string claimDesc = DescribeClaim(playerUid, claimIndex);
return Stage(
playerUid,
groupId,
$"'{claimDesc}' will be linked into '{groupName}'.",
() =>
{
bool stillMember =
ClaimLinkModSystem
.PlayerData.GetPlayerDataByUid(playerUid)
?.PlayerGroupMemberships.ContainsKey(groupId) == true;
if (!stillMember)
return TextCommandResult.Error($"You are no longer a member of '{groupName}'.");
bool success = ClaimLinkModSystem.Registry.AddEntry(playerUid, groupId, claimIndex);
return success
? TextCommandResult.Success($"Linked '{claimDesc}' to '{groupName}'.")
: TextCommandResult.Error(
$"Unable to link claim {claimIndex} to '{groupName}', it is currently being modified."
);
}
);
}
public static TextCommandResult Unlink(TextCommandCallingArgs args)
{
int claimIndex = (int)args[0];
string playerUid = args.Caller.Player.PlayerUID;
int? groupId = ClaimLinkModSystem.Registry.FindGroupContaining(playerUid, claimIndex);
if (groupId == null)
return TextCommandResult.Error(
$"Claim {claimIndex} is not linked into any claim link by you."
);
string groupName = ClaimLinkModSystem.Groups.PlayerGroupsById[(int)groupId].Name;
string claimDesc = DescribeClaim(playerUid, claimIndex);
return Stage(
playerUid,
(int)groupId,
$"'{claimDesc}' will be unlinked from '{groupName}'.",
() =>
{
ClaimLinkModSystem.Registry.RemoveEntry(playerUid, (int)groupId, claimIndex);
return TextCommandResult.Success($"Unlinked '{claimDesc}' from '{groupName}'.");
}
);
}
public static TextCommandResult Kick(TextCommandCallingArgs args)
{
string word1 = (string)args[0];
string? word2 = args.Parsers[1].IsMissing ? null : (string)args[1];
string targetName;
PlayerGroup group;
if (word2 != null)
{
TextCommandResult? err = TryResolveGroup(word1, out group);
if (err != null)
return err;
targetName = word2;
}
else
{
int chatGroupId = args.Caller.FromChatGroupId;
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(chatGroupId, out group!))
return TextCommandResult.Error(
"No group specified and you are not sending this from a group chat channel."
);
targetName = word1;
}
TextCommandResult? linkErr = TryResolveClaimLink(group);
if (linkErr != null)
return linkErr;
linkErr = RequireOpOrOwner(args.Caller.Player, group);
if (linkErr != null)
return linkErr;
string? targetUid = ClaimLinkModSystem
.PlayerData.GetPlayerDataByLastKnownName(targetName)
?.PlayerUID;
if (targetUid == null)
return TextCommandResult.Error($"No such player '{targetName}'.");
string playerUid = args.Caller.Player.PlayerUID;
string groupName = group.Name;
if (!ClaimLinkModSystem.Registry.HasAnyEntry(targetUid, group.Uid))
return TextCommandResult.Error($"{targetName} has no claims linked in '{groupName}'.");
return Stage(
playerUid,
group.Uid,
$"All of {targetName}'s claims will be unlinked from '{groupName}'.",
() =>
{
ClaimLinkModSystem.Registry.RemoveAllForPlayerInGroup(targetUid, group.Uid);
CommandListener.PendingLoads.Remove(targetUid);
PendingActions.Remove(targetUid);
return TextCommandResult.Success(
$"Unlinked all claims of {targetName} from '{groupName}'."
);
}
);
}
public static TextCommandResult Delete(TextCommandCallingArgs args)
{
string playerUid = args.Caller.Player.PlayerUID;
TextCommandResult? err = TryResolveGroupArg(args, 0, out PlayerGroup group);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
err = RequireOwner(args.Caller.Player, group);
if (err != null)
return err;
int groupId = group.Uid;
string groupName = group.Name;
return Stage(
playerUid,
groupId,
$"'{groupName}' will be deleted as a claim link.",
() =>
{
RemovePendingActionsForGroup(groupId, "claim link was deleted");
ClaimLinkModSystem.Registry.Remove(groupId);
return TextCommandResult.Success($"'{groupName}' is no longer a claim link.");
}
);
}
public static TextCommandResult TransferOwnership(TextCommandCallingArgs args)
{
string word1 = (string)args[0];
string? word2 = args.Parsers[1].IsMissing ? null : (string)args[1];
string targetName;
PlayerGroup group;
if (word2 != null)
{
TextCommandResult? groupErr = TryResolveGroup(word1, out group);
if (groupErr != null)
return groupErr;
targetName = word2;
}
else
{
int chatGroupId = args.Caller.FromChatGroupId;
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(chatGroupId, out group!))
return TextCommandResult.Error(
"No group specified and you are not sending this from a group chat channel."
);
targetName = word1;
}
IPlayer player = args.Caller.Player;
string playerUid = player.PlayerUID;
TextCommandResult? err = TryResolveOnlinePlayerByName(targetName, out IPlayer target);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
err = RequireOwner(player, group);
if (err != null)
return err;
err = RequireMember(target, group);
if (err != null)
return err;
err = RequireNotAlreadyOwner(group, target);
if (err != null)
return err;
string groupName = group.Name;
return Stage(
playerUid,
group.Uid,
$"Ownership of '{groupName}' will be transferred to {target.PlayerName}.",
() => ExecuteTransferOwnership(group, target)
);
}
private static TextCommandResult? TryResolveOnlinePlayerByName(string name, out IPlayer target)
{
target = null!;
foreach (IPlayer online in ClaimLinkModSystem.World.AllOnlinePlayers)
{
if (string.Equals(online.PlayerName, name, StringComparison.OrdinalIgnoreCase))
{
target = online;
return null;
}
}
return TextCommandResult.Error($"No such player '{name}' online.");
}
private static TextCommandResult? TryResolveTargetPlayer(
PlayerUidName[] matches,
out IPlayer target
)
{
target = null!;
if (matches.Length != 1)
return TextCommandResult.Error("Name a single player.");
IPlayer? resolved = ClaimLinkModSystem.World.PlayerByUid(matches[0].Uid);
if (resolved == null)
return TextCommandResult.Error($"No such player '{matches[0].Name}'.");
target = resolved;
return null;
}
private static TextCommandResult? RequireNotAlreadyOwner(PlayerGroup group, IPlayer target)
{
return target.PlayerUID == group.OwnerUID
? TextCommandResult.Error($"{target.PlayerName} already owns '{group.Name}'.")
: null;
}
private static TextCommandResult ExecuteTransferOwnership(PlayerGroup group, IPlayer target)
{
string oldOwnerUid = group.OwnerUID;
IPlayer? oldOwner = ClaimLinkModSystem.World.PlayerByUid(oldOwnerUid);
PlayerGroupMembership? oldOwnerMembership = oldOwner?.GetGroup(group.Uid);
PlayerGroupMembership targetMembership = target.GetGroup(group.Uid)!;
group.OwnerUID = target.PlayerUID;
targetMembership.Level = EnumPlayerGroupMemberShip.Owner;
if (oldOwnerMembership != null)
oldOwnerMembership.Level = EnumPlayerGroupMemberShip.Op;
PendingActions.Remove(oldOwnerUid);
string notice = $"'{group.Name}' is now owned by {target.PlayerName}.";
HashSet<string> notifyUids = ClaimLinkModSystem
.Registry.MemberUidsForGroup(group.Uid)
.ToHashSet();
notifyUids.Add(oldOwnerUid);
notifyUids.Add(target.PlayerUID);
foreach (string uid in notifyUids)
NotifyIfOnline(ClaimLinkModSystem.World.PlayerByUid(uid), notice);
return TextCommandResult.Success(notice);
}
private static void NotifyIfOnline(IPlayer? player, string message)
{
if (player is IServerPlayer sp && sp.ConnectionState == EnumClientState.Playing)
sp.SendMessage(GlobalConstants.GeneralChatGroup, message, EnumChatType.Notification);
}
public static TextCommandResult Info(TextCommandCallingArgs args)
{
TextCommandResult? err = TryResolveGroupArg(args, 0, out PlayerGroup group);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
IPlayer player = args.Caller.Player;
bool showDetails =
player.HasPrivilege(Privilege.controlserver) || player.GetGroup(group.Uid) != null;
return TextCommandResult.Success(FormatInfo(group.Name, group.Uid, showDetails));
}
public static TextCommandResult List(TextCommandCallingArgs args)
{
List<int> groupIds = new(ClaimLinkModSystem.Registry.All);
groupIds.Sort((a, b) => GroupMemberCount(b).CompareTo(GroupMemberCount(a)));
if (groupIds.Count == 0)
return TextCommandResult.Success("There are no claim links.");
StringBuilder sb = new();
int shown = 0;
foreach (int groupId in groupIds)
{
if (
!ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(
groupId,
out PlayerGroup? group
)
)
{
RemovePendingActionsForGroup(groupId, "group no longer exists");
ClaimLinkModSystem.Registry.Remove(groupId);
continue;
}
int memberCount = GroupMemberCount(groupId);
sb.AppendLine($" {group.Name}: {memberCount} member{(memberCount == 1 ? "" : "s")}");
shown++;
}
if (shown == 0)
return TextCommandResult.Success("There are no claim links.");
return TextCommandResult.Success($"Claim links ({shown}):\n{sb}");
}
private static IEnumerable<string> GroupMemberUids(int groupId)
{
foreach (var playerData in ClaimLinkModSystem.PlayerData.PlayerDataByUid.Values)
if (playerData.PlayerGroupMemberships.ContainsKey(groupId))
yield return playerData.PlayerUID;
}
private static int GroupMemberCount(int groupId)
{
int count = 0;
foreach (string _ in GroupMemberUids(groupId))
count++;
return count;
}
private static string DescribeClaim(string ownerPlayerUid, int claimIndex)
{
if (
!ClaimLinkModSystem.TryResolveOwnedClaim(
ownerPlayerUid,
claimIndex,
out LandClaim? claim
)
|| claim == null
)
return $"claim {claimIndex}";
return string.IsNullOrEmpty(claim.Description) ? $"claim {claimIndex}" : claim.Description;
}
private static string FormatInfo(string groupName, int groupId, bool showDetails)
{
int memberCount = GroupMemberCount(groupId);
StringBuilder sb = new();
sb.AppendLine(
$"Claim link '{groupName}' ({memberCount} member{(memberCount == 1 ? "" : "s")}):"
);
foreach (string uid in GroupMemberUids(groupId))
{
string name = ClaimLinkModSystem.World.PlayerByUid(uid)?.PlayerName ?? uid;
if (!showDetails)
{
sb.AppendLine($" {name}");
continue;
}
IEnumerable<string> claims = ClaimLinkModSystem
.Registry.ClaimsForPlayerInGroup(uid, groupId)
.Select(i => DescribeClaim(uid, i));
sb.AppendLine($" {name}: [{string.Join(", ", claims)}]");
}
return sb.ToString();
}
public static TextCommandResult AdminDelete(TextCommandCallingArgs args)
{
TextCommandResult? err = TryResolveGroupArg(args, 0, out PlayerGroup group);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
int groupId = group.Uid;
string groupName = group.Name;
RemovePendingActionsForGroup(groupId, "claim link was deleted");
ClaimLinkModSystem.Registry.Remove(groupId);
return TextCommandResult.Success($"'{groupName}' is no longer a claim link.");
}
public static TextCommandResult AdminUnlink(TextCommandCallingArgs args)
{
string playerName = (string)args[0];
int claimIndex = (int)args[1];
string? targetUid = ClaimLinkModSystem
.PlayerData.GetPlayerDataByLastKnownName(playerName)
?.PlayerUID;
if (targetUid == null)
return TextCommandResult.Error($"No such player '{playerName}'.");
int? groupId = ClaimLinkModSystem.Registry.FindGroupContaining(targetUid, claimIndex);
if (groupId == null)
return TextCommandResult.Error(
$"Claim {claimIndex} is not linked into any claim link by {playerName}."
);
string groupName = ClaimLinkModSystem.Groups.PlayerGroupsById[(int)groupId].Name;
string claimDesc = DescribeClaim(targetUid, claimIndex);
ClaimLinkModSystem.Registry.RemoveEntry(targetUid, (int)groupId, claimIndex);
return TextCommandResult.Success(
$"Unlinked '{claimDesc}' from '{groupName}' (owned by {playerName})."
);
}
public static TextCommandResult AdminKick(TextCommandCallingArgs args)
{
string word1 = (string)args[0];
string? word2 = args.Parsers[1].IsMissing ? null : (string)args[1];
string targetName;
PlayerGroup group;
if (word2 != null)
{
TextCommandResult? err = TryResolveGroup(word1, out group);
if (err != null)
return err;
targetName = word2;
}
else
{
int chatGroupId = args.Caller.FromChatGroupId;
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(chatGroupId, out group!))
return TextCommandResult.Error(
"No group specified and you are not sending this from a group chat channel."
);
targetName = word1;
}
TextCommandResult? linkErr = TryResolveClaimLink(group);
if (linkErr != null)
return linkErr;
string? targetUid = ClaimLinkModSystem
.PlayerData.GetPlayerDataByLastKnownName(targetName)
?.PlayerUID;
if (targetUid == null)
return TextCommandResult.Error($"No such player '{targetName}'.");
string groupName = group.Name;
if (!ClaimLinkModSystem.Registry.HasAnyEntry(targetUid, group.Uid))
return TextCommandResult.Error($"{targetName} has no claims linked in '{groupName}'.");
ClaimLinkModSystem.Registry.RemoveAllForPlayerInGroup(targetUid, group.Uid);
CommandListener.PendingLoads.Remove(targetUid);
PendingActions.Remove(targetUid);
return TextCommandResult.Success(
$"Unlinked all claims of {targetName} from '{groupName}'."
);
}
public static TextCommandResult AdminTransferOwnership(TextCommandCallingArgs args)
{
TextCommandResult? err = TryResolveTargetPlayer(
(PlayerUidName[])args[0],
out IPlayer target
);
if (err != null)
return err;
err = TryResolveGroupArg(args, 1, out PlayerGroup group);
if (err != null)
return err;
err = TryResolveClaimLink(group);
if (err != null)
return err;
err = RequireMember(target, group);
if (err != null)
return err;
err = RequireNotAlreadyOwner(group, target);
if (err != null)
return err;
return ExecuteTransferOwnership(group, target);
}
internal static void OnPlayerDisconnect(IServerPlayer player)
{
PendingActions.Remove(player.PlayerUID);
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace ClaimLink;
internal class ClaimLinkConfig
{
public int BufferSize = 16;
}
+86 -8
View File
@@ -1,4 +1,5 @@
using CommandHook; using System.Collections.Generic;
using CommandHook;
using Vintagestory.API.Common; using Vintagestory.API.Common;
using Vintagestory.API.Server; using Vintagestory.API.Server;
@@ -6,22 +7,99 @@ namespace ClaimLink;
public class ClaimLinkModSystem : ModSystem public class ClaimLinkModSystem : ModSystem
{ {
internal ClaimLinkCommandListener? cmdListener; internal static ILandClaimAPI LandClaimAPI = null!;
internal static ILogger Logger = null!;
internal static ClaimLinkRegistry Registry = null!;
internal static IGroupManager Groups = null!;
internal static IWorldAccessor World = null!;
internal static IPlayerDataManager PlayerData = null!;
internal static ClaimLinkConfig Config = null!;
internal CommandListener? CmdListener;
internal GroupCommandListener? GroupCmdListener;
public override bool ShouldLoad(EnumAppSide forSide) => forSide == EnumAppSide.Server; public override bool ShouldLoad(EnumAppSide forSide) => forSide == EnumAppSide.Server;
public override void StartServerSide(ICoreServerAPI api) public override void StartServerSide(ICoreServerAPI api)
{ {
cmdListener = new ClaimLinkCommandListener(); LandClaimAPI = api.World.Claims;
CommandHookModSystem.Register(cmdListener); World = api.World;
Groups = api.Groups;
Logger = api.Logger;
PlayerData = api.PlayerData;
Config = api.LoadModConfig<ClaimLinkConfig>("claimlink.json") ?? new ClaimLinkConfig();
api.StoreModConfig(Config, "claimlink.json");
Registry = new ClaimLinkRegistry(api.WorldManager.SaveGame);
CmdListener = new CommandListener();
CommandHookModSystem.Register(CmdListener);
GroupCmdListener = new GroupCommandListener();
CommandHookModSystem.Register(GroupCmdListener);
ClaimLinkChatCommand.Register(api);
ClaimLinkVisualizerNetwork.Start(api);
api.Event.PlayerDisconnect += CommandListener.OnPlayerDisconnect;
api.Event.PlayerDisconnect += GroupCommandListener.OnPlayerDisconnect;
api.Event.PlayerDisconnect += ClaimLinkChatCommand.OnPlayerDisconnect;
} }
public override void Dispose() public override void Dispose()
{ {
if (cmdListener == null) if (CmdListener != null)
return; CommandHookModSystem.Unregister(CmdListener);
if (GroupCmdListener != null)
CommandHookModSystem.Unregister(GroupCmdListener);
}
CommandHookModSystem.Unregister(cmdListener); private static IEnumerable<(int claimIndex, LandClaim claim)> EnumerateOwnedClaims(
string ownerPlayerUid
)
{
int index = 0;
foreach (LandClaim c in LandClaimAPI.All)
{
if (c.OwnedByPlayerUid != ownerPlayerUid)
continue;
yield return (index, c);
index++;
}
}
public static bool TryResolveOwnedClaim(
string ownerPlayerUid,
int claimIndex,
out LandClaim? claim
)
{
foreach ((int index, LandClaim c) in EnumerateOwnedClaims(ownerPlayerUid))
{
if (index == claimIndex)
{
claim = c;
return true;
}
}
claim = null;
return false;
}
internal static bool TryResolveClaimIndex(
string ownerPlayerUid,
LandClaim target,
out int claimIndex
)
{
foreach ((int index, LandClaim c) in EnumerateOwnedClaims(ownerPlayerUid))
{
if (ReferenceEquals(c, target))
{
claimIndex = index;
return true;
}
}
claimIndex = -1;
return false;
} }
} }
+201
View File
@@ -0,0 +1,201 @@
using System.Collections.Generic;
using Vintagestory.API.Server;
namespace ClaimLink;
public class ClaimLinkRegistry
{
private const string SaveKey = "claimlink:claimlinkdata";
private readonly ISaveGame saveGame;
private readonly List<int> claimLinks = new();
private readonly Dictionary<string, Dictionary<int, List<int>>> entries = new();
public ClaimLinkRegistry(ISaveGame saveGame)
{
this.saveGame = saveGame;
ClaimLinkSaveData? data = saveGame.GetData<ClaimLinkSaveData?>(SaveKey, null);
if (data == null)
return;
claimLinks.AddRange(data.Links);
foreach (var entry in data.Entries)
{
if (!entries.ContainsKey(entry.OwnerPlayerUid))
entries[entry.OwnerPlayerUid] = new Dictionary<int, List<int>>();
entries[entry.OwnerPlayerUid][entry.GroupId] = new(entry.ClaimIndicies);
}
}
public IReadOnlyList<int> All => claimLinks;
public bool Exists(int groupId) => claimLinks.Contains(groupId);
public void Add(int groupId)
{
claimLinks.Add(groupId);
Save();
}
public void Remove(int groupId)
{
claimLinks.Remove(groupId);
foreach (var groups in entries.Values)
groups.Remove(groupId);
Prune();
Save();
}
public bool IsClaimLinked(string uid, int claimIndex) =>
FindGroupContaining(uid, claimIndex) != null;
public int? FindGroupContaining(string uid, int claimIndex)
{
if (!entries.TryGetValue(uid, out var groups))
return null;
foreach (var (groupId, claims) in groups)
if (claims.Contains(claimIndex))
return groupId;
return null;
}
public bool AddEntry(string uid, int groupId, int claimIndex)
{
if (CommandListener.PendingLoads.ContainsKey(uid))
return false;
if (!Exists(groupId))
return false;
if (!entries.TryGetValue(uid, out var groups))
entries[uid] = groups = new();
if (!groups.TryGetValue(groupId, out var claims))
groups[groupId] = claims = new();
claims.Add(claimIndex);
Save();
return true;
}
public void RemoveEntry(string uid, int groupId, int claimIndex)
{
if (entries.TryGetValue(uid, out var groups) && groups.TryGetValue(groupId, out var claims))
claims.Remove(claimIndex);
Prune();
Save();
}
public void ClaimRemoved(string uid, int removedIndex)
{
if (!entries.TryGetValue(uid, out var groups))
return;
foreach (var claims in groups.Values)
{
claims.Remove(removedIndex);
for (int i = 0; i < claims.Count; i++)
if (claims[i] > removedIndex)
claims[i]--;
}
Prune();
Save();
}
public void ClaimSaved(string uid, int claimIndex)
{
if (!entries.TryGetValue(uid, out var groups))
return;
int count = 0;
foreach (Vintagestory.API.Common.LandClaim c in ClaimLinkModSystem.LandClaimAPI.All)
if (c.OwnedByPlayerUid == uid)
count++;
int newIndex = count - 1;
foreach (var claims in groups.Values)
{
int i = claims.IndexOf(claimIndex);
for (int j = 0; j < claims.Count; j++)
if (j != i && claims[j] > claimIndex)
claims[j]--;
if (i >= 0)
claims[i] = newIndex;
}
Save();
}
public bool HasAnyEntry(string uid, int groupId) =>
entries.TryGetValue(uid, out var groups)
&& groups.TryGetValue(groupId, out var claims)
&& claims.Count > 0;
public void RemoveAllForPlayerInGroup(string uid, int groupId)
{
if (entries.TryGetValue(uid, out var groups))
groups.Remove(groupId);
Prune();
Save();
}
public void ClearAllForPlayer(string uid)
{
entries.Remove(uid);
Save();
}
public IEnumerable<string> MemberUidsForGroup(int groupId)
{
foreach (var (uid, groups) in entries)
if (groups.ContainsKey(groupId))
yield return uid;
}
public int MemberCountForGroup(int groupId)
{
int count = 0;
foreach (var uid in MemberUidsForGroup(groupId))
count++;
return count;
}
public IReadOnlyList<int> ClaimsForPlayerInGroup(string uid, int groupId) =>
entries.TryGetValue(uid, out var groups) && groups.TryGetValue(groupId, out var claims)
? claims
: System.Array.Empty<int>();
private void Prune()
{
List<string>? empty = null;
foreach (var (uid, groups) in entries)
if (groups.Count == 0)
(empty ??= new()).Add(uid);
if (empty != null)
foreach (var uid in empty)
entries.Remove(uid);
}
public void Save()
{
var entryList = new List<ClaimLinkEntrySaveData>();
foreach (var (uid, groups) in entries)
foreach (var (groupId, claims) in groups)
entryList.Add(
new ClaimLinkEntrySaveData
{
OwnerPlayerUid = uid,
GroupId = groupId,
ClaimIndicies = new(claims),
}
);
saveGame.StoreData(
SaveKey,
new ClaimLinkSaveData { Links = claimLinks, Entries = entryList }
);
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using ProtoBuf;
using Vintagestory.API.MathTools;
using Vintagestory.API.Server;
namespace ClaimLink;
[ProtoContract]
public class ClaimLinkBufferRequest { }
[ProtoContract]
public class ClaimLinkBufferCuboidDto
{
[ProtoMember(1)]
public int MinX;
[ProtoMember(2)]
public int MinY;
[ProtoMember(3)]
public int MinZ;
[ProtoMember(4)]
public int MaxX;
[ProtoMember(5)]
public int MaxY;
[ProtoMember(6)]
public int MaxZ;
}
[ProtoContract]
public class ClaimLinkBufferEntryDto
{
[ProtoMember(1)]
public int GroupId;
[ProtoMember(2)]
public List<ClaimLinkBufferCuboidDto> Cuboids = new();
}
[ProtoContract]
public class ClaimLinkBufferResponse
{
[ProtoMember(1)]
public List<ClaimLinkBufferEntryDto> Entries = new();
}
internal static class ClaimLinkVisualizerNetwork
{
internal const string ChannelName = "claimlinkvisualizer";
internal static void Start(ICoreServerAPI api)
{
api.Network
.RegisterChannel(ChannelName)
.RegisterMessageType<ClaimLinkBufferRequest>()
.RegisterMessageType<ClaimLinkBufferResponse>()
.SetMessageHandler<ClaimLinkBufferRequest>((fromPlayer, _) =>
SendBuffers(api, fromPlayer)
);
}
private static void SendBuffers(ICoreServerAPI api, IServerPlayer toPlayer)
{
ClaimLinkBufferResponse response = new();
BlockPos playerPos = toPlayer.Entity.Pos.AsBlockPos;
int radius = toPlayer.WorldData.LastApprovedViewDistance;
foreach (int groupId in ClaimLinkModSystem.Registry.All)
{
ClaimLinkBufferEntryDto entry = new() { GroupId = groupId };
foreach (Cuboidi cuboid in ClaimLinkBuffer.GetBuffer(groupId))
{
if (!IsNearby(cuboid, playerPos, radius))
continue;
entry.Cuboids.Add(
new ClaimLinkBufferCuboidDto
{
MinX = cuboid.MinX,
MinY = cuboid.MinY,
MinZ = cuboid.MinZ,
MaxX = cuboid.MaxX,
MaxY = cuboid.MaxY,
MaxZ = cuboid.MaxZ,
}
);
}
if (entry.Cuboids.Count > 0)
response.Entries.Add(entry);
}
api.Network.GetChannel(ChannelName).SendPacket(response, toPlayer);
}
private static bool IsNearby(Cuboidi cuboid, BlockPos playerPos, int radius) =>
cuboid.MinX <= playerPos.X + radius
&& cuboid.MaxX >= playerPos.X - radius
&& cuboid.MinY <= playerPos.Y + radius
&& cuboid.MaxY >= playerPos.Y - radius
&& cuboid.MinZ <= playerPos.Z + radius
&& cuboid.MaxZ >= playerPos.Z - radius;
}
+416
View File
@@ -0,0 +1,416 @@
using System.Collections.Generic;
using CommandHook;
using Vintagestory.API.Common;
using Vintagestory.API.Config;
using Vintagestory.API.MathTools;
using Vintagestory.API.Server;
namespace ClaimLink;
public class CommandListener : ICommandHookListener
{
public string ModId => "claimlink";
public IReadOnlyList<string> Commands => new[] { "land" };
public CommandRegistration Registration => new(Before, After);
internal static Dictionary<string, int> PendingLoads = new Dictionary<string, int>();
internal static Dictionary<string, List<Cuboidi>?> PendingSaveChecks =
new Dictionary<string, List<Cuboidi>?>();
private delegate void SubHandler(Caller caller, CmdArgs args);
private static readonly Dictionary<string, SubHandler> topLevel = new()
{
["claim"] = Claim,
["free"] = Free,
["adminfree"] = AdminFree,
["adminfreehere"] = AdminFreeHere,
};
private static readonly Dictionary<string, SubHandler> claimSub = new()
{
["load"] = ClaimLoad,
["new"] = ClaimNew,
["save"] = ClaimSave,
["cancel"] = ClaimCancel,
};
private static void Dispatch(TextCommandCallingArgs args)
{
CmdArgs rawArgs = args.RawArgs.Clone();
while (rawArgs.Length > 0)
{
string word = rawArgs.PeekWord();
if (topLevel.TryGetValue(word, out SubHandler? handler))
{
rawArgs.PopWord();
handler(args.Caller, rawArgs);
}
else
{
rawArgs.PopWord();
}
}
}
private TextCommandResult? Before(TextCommandCallingArgs args)
{
Dispatch(args);
return null;
}
private void After(TextCommandCallingArgs args, TextCommandResult result)
{
if (args.Caller.Player == null)
return;
string uid = args.Caller.Player.PlayerUID;
if (!PendingSaveChecks.Remove(uid, out List<Cuboidi>? snapshot))
return;
if (result.Status != EnumCommandStatus.Success)
return;
List<LandClaim> allClaims = ClaimLinkModSystem.LandClaimAPI.All;
if (allClaims.Count == 0)
return;
LandClaim savedClaim = allClaims[allClaims.Count - 1];
if (savedClaim.OwnedByPlayerUid != uid)
return;
int? linkedGroupId = null;
if (ClaimLinkModSystem.TryResolveClaimIndex(uid, savedClaim, out int savedIndex))
linkedGroupId = ClaimLinkModSystem.Registry.FindGroupContaining(uid, savedIndex);
bool isLinked = linkedGroupId != null;
List<Cuboidi> checkAreas = isLinked
? ClaimLinkBuffer.GrowAreas(savedClaim.Areas)
: savedClaim.Areas;
ISet<int> excludeGroupIds = isLinked
? new HashSet<int> { linkedGroupId!.Value }
: ClaimLinkBuffer.MemberClaimLinkIds(uid);
LandClaim? violatingClaim = null;
bool violatesClaimLink = ClaimLinkBuffer.TryFindOverlappingClaimLink(
checkAreas,
excludeGroupIds,
out int violatingGroupId
);
bool violatesClaim =
isLinked
&& !violatesClaimLink
&& ClaimLinkBuffer.TryFindOverlappingClaim(
checkAreas,
uid,
excludeGroupIds,
out violatingClaim
);
if (!violatesClaimLink && !violatesClaim)
return;
ClaimLinkModSystem.LandClaimAPI.Remove(savedClaim);
string reason = violatesClaimLink
? (
ClaimLinkModSystem.Groups.PlayerGroupsById.TryGetValue(
violatingGroupId,
out PlayerGroup? violatingGroup
)
? $"it overlaps the protected buffer of '{violatingGroup!.Name}'"
: "it overlaps the protected buffer of another claim link"
)
: $"its buffer would overlap the claim owned by {violatingClaim!.LastKnownOwnerName}";
if (snapshot != null)
{
savedClaim.Areas.Clear();
savedClaim.Areas.AddRange(snapshot);
ClaimLinkModSystem.LandClaimAPI.Add(savedClaim);
NotifyPlayer(args.Caller.Player, $"Your claim growth was reverted: {reason}.");
}
else
{
NotifyPlayer(args.Caller.Player, $"Your claim was rejected: {reason}.");
}
}
private static void NotifyPlayer(IPlayer player, string message)
{
if (player is IServerPlayer sp)
sp.SendMessage(GlobalConstants.GeneralChatGroup, message, EnumChatType.Notification);
}
private static void Claim(Caller caller, CmdArgs args)
{
string? word = args.PeekWord();
if (word == null)
return;
if (claimSub.TryGetValue(word, out SubHandler? handler))
{
args.PopWord();
handler(caller, args);
}
}
private static void ClaimSave(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
string uid = caller.Player.PlayerUID;
PendingSaveChecks[uid] = null;
if (!PendingLoads.TryGetValue(uid, out int pendingIndex))
return;
if (
ClaimLinkModSystem.TryResolveOwnedClaim(uid, pendingIndex, out LandClaim? existingClaim)
&& existingClaim != null
)
{
List<Cuboidi> snapshot = new();
foreach (Cuboidi area in existingClaim.Areas)
snapshot.Add(area.Clone());
PendingSaveChecks[uid] = snapshot;
}
ClaimLinkModSystem.Registry.ClaimSaved(uid, pendingIndex);
PendingLoads.Remove(uid);
ClaimLinkChatCommand.PendingActions.Remove(uid);
}
private static void ClaimLoad(Caller caller, CmdArgs args)
{
int? claimIndex = args.PopInt();
if (caller.Player == null || claimIndex == null)
return;
if (
ClaimLinkModSystem.TryResolveOwnedClaim(caller.Player.PlayerUID, (int)claimIndex, out _)
)
PendingLoads[caller.Player.PlayerUID] = (int)claimIndex;
}
private static void ClaimNew(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
PendingLoads.Remove(caller.Player.PlayerUID);
}
private static void ClaimCancel(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
PendingLoads.Remove(caller.Player.PlayerUID);
}
private static void Free(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
string uid = caller.Player.PlayerUID;
int? claimIndex = args.PopInt();
if (claimIndex == null)
return;
if (args.PopWord() != "confirm")
return;
ClaimLinkModSystem.Registry.ClaimRemoved(uid, (int)claimIndex);
ClaimLinkChatCommand.PendingActions.Remove(uid);
}
private static void AdminFree(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
string? name = args.PopWord();
if (name == null)
return;
string? uid = ClaimLinkModSystem.PlayerData.GetPlayerDataByLastKnownName(name)?.PlayerUID;
if (uid == null)
return;
ClaimLinkModSystem.Registry.ClearAllForPlayer(uid);
ClaimLinkChatCommand.PendingActions.Remove(uid);
}
private static void AdminFreeHere(Caller caller, CmdArgs args)
{
if (caller.Player == null)
return;
LandClaim[] claims = ClaimLinkModSystem.LandClaimAPI.Get(
caller.Player.Entity.Pos.AsBlockPos
);
foreach (LandClaim c in claims)
if (ClaimLinkModSystem.TryResolveClaimIndex(c.OwnedByPlayerUid, c, out int claimIndex))
{
ClaimLinkModSystem.Registry.ClaimRemoved(c.OwnedByPlayerUid, claimIndex);
ClaimLinkChatCommand.PendingActions.Remove(c.OwnedByPlayerUid);
}
}
internal static void OnPlayerDisconnect(IServerPlayer player)
{
PendingLoads.Remove(player.PlayerUID);
PendingSaveChecks.Remove(player.PlayerUID);
}
}
public class GroupCommandListener : ICommandHookListener
{
public string ModId => "claimlink.group";
public IReadOnlyList<string> Commands => new[] { "group" };
public CommandRegistration Registration => new(Before, After);
internal static Dictionary<string, int> PendingDisbands = new Dictionary<string, int>();
internal static Dictionary<string, (string AffectedUid, int GroupId)> PendingMembershipChecks =
new Dictionary<string, (string, int)>();
private TextCommandResult? Before(TextCommandCallingArgs args)
{
if (args.Caller.Player == null)
return null;
string callerUid = args.Caller.Player.PlayerUID;
CmdArgs rawArgs = args.RawArgs.Clone();
string? word = rawArgs.PopWord();
switch (word)
{
case "confirmdisband":
ConfirmDisband(args, callerUid, rawArgs);
break;
case "leave":
Leave(args, callerUid, rawArgs);
break;
case "kick":
Kick(args, callerUid, rawArgs);
break;
}
return null;
}
private static void ConfirmDisband(
TextCommandCallingArgs args,
string callerUid,
CmdArgs rawArgs
)
{
string? name = rawArgs.PopWord();
int? groupId =
name != null
? ClaimLinkModSystem.Groups.GetPlayerGroupByName(name)?.Uid
: args.Caller.FromChatGroupId;
if (groupId != null)
PendingDisbands[callerUid] = (int)groupId;
}
private static void Leave(TextCommandCallingArgs args, string callerUid, CmdArgs rawArgs)
{
string? name = rawArgs.PopWord();
int? groupId =
name != null
? ClaimLinkModSystem.Groups.GetPlayerGroupByName(name)?.Uid
: args.Caller.FromChatGroupId;
if (groupId != null)
PendingMembershipChecks[callerUid] = (callerUid, (int)groupId);
}
private static void Kick(TextCommandCallingArgs args, string callerUid, CmdArgs rawArgs)
{
string? word1 = rawArgs.PopWord();
string? word2 = rawArgs.PopWord();
string? targetName;
int? groupId;
if (word2 != null)
{
groupId =
word1 != null ? ClaimLinkModSystem.Groups.GetPlayerGroupByName(word1)?.Uid : null;
targetName = word2;
}
else
{
targetName = word1;
groupId = args.Caller.FromChatGroupId;
}
if (targetName == null || groupId == null)
return;
string? targetUid = ClaimLinkModSystem
.PlayerData.GetPlayerDataByLastKnownName(targetName)
?.PlayerUID;
if (targetUid != null)
PendingMembershipChecks[callerUid] = (targetUid, (int)groupId);
}
private void After(TextCommandCallingArgs args, TextCommandResult result)
{
if (args.Caller.Player == null)
return;
string callerUid = args.Caller.Player.PlayerUID;
if (PendingDisbands.TryGetValue(callerUid, out int disbandedGroupId))
{
PendingDisbands.Remove(callerUid);
if (!ClaimLinkModSystem.Groups.PlayerGroupsById.ContainsKey(disbandedGroupId))
{
ClaimLinkChatCommand.RemovePendingActionsForGroup(
disbandedGroupId,
"group no longer exists"
);
if (ClaimLinkModSystem.Registry.Exists(disbandedGroupId))
ClaimLinkModSystem.Registry.Remove(disbandedGroupId);
}
}
if (PendingMembershipChecks.TryGetValue(callerUid, out var check))
{
PendingMembershipChecks.Remove(callerUid);
bool stillMember =
ClaimLinkModSystem
.PlayerData.GetPlayerDataByUid(check.AffectedUid)
?.PlayerGroupMemberships.ContainsKey(check.GroupId) == true;
if (!stillMember)
ClaimLinkChatCommand.CancelPendingAction(
check.AffectedUid,
check.GroupId,
"you are no longer a member of the group"
);
}
}
internal static void OnPlayerDisconnect(IServerPlayer player)
{
PendingDisbands.Remove(player.PlayerUID);
PendingMembershipChecks.Remove(player.PlayerUID);
}
}
-24
View File
@@ -1,24 +0,0 @@
using Vintagestory.API.Common;
using System.Collections.Generic;
using CommandHook;
namespace ClaimLink;
public class ClaimLinkCommandListener : ICommandHookListener
{
public string ModId => "claimlink";
public IReadOnlyList<string> Commands => new[] { "land" };
public CommandRegistration Registration => new(Before, After);
private TextCommandResult? Before(TextCommandCallingArgs args)
{
return null;
}
private void After(TextCommandCallingArgs args, TextCommandResult result)
{
}
}
+2 -2
View File
@@ -8,9 +8,9 @@
"anth64" "anth64"
], ],
"description": "Link claims together with groups.", "description": "Link claims together with groups.",
"version": "0.0.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"game": "1.22.3", "game": "1.22.5",
"commandhook": "2.1.0" "commandhook": "2.1.0"
} }
} }