feat(game): slash commands accept permission grants

A command may be used when the character's GM level allows it or a grant does: a grant of the command, of every
command up to a GM level, or of the dashboard permission the command follows (so accounts_kick covers /kick). A deny
of any of those takes it away even when the level allows it, except from GM 9 accounts (also while they play at a
lower level). Grants never take anyone below a command's floor above GM 1 (/execute), and commands the client handles
keep their fixed level. Expired grants count for nothing. The grants are the account's and the logged-in
character's, read from the database the first time a command is used and kept on the User. /help lists the commands
a player may use this way. The self and rank rules for commands that act on another player count grants of self_* and
manage_equal_rank too. A denied command says it was taken away.

Check: grant a GM 0 account /spawn (or "every command up to GM 8") on the dashboard, relog, and /spawn works and
shows in /help; deny /spawn from a GM 8 character: "it was taken away"; grant accounts_kick and /kick works (the rank
rules still apply to whom). dGameTests SlashCommandGrantsTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:11:17 -05:00
parent 868f34123f
commit e00d22152e
4 changed files with 186 additions and 10 deletions

View File

@@ -8,10 +8,12 @@
#include "dCommonVars.h"
#include "eFunnessTypes.h"
#include <memory>
#include <unordered_map>
class Character;
enum class eGameMasterLevel : uint8_t;
namespace PermissionGrants { struct Held; }
struct BehaviorParams {
uint32_t behavior;
@@ -59,6 +61,13 @@ public:
void UserOutOfSync(const CaughtFunness& funness);
// The permission grants of this account and of the character they were loaded for (SlashCommandHandler loads them
// when first needed; nullptr until then, and again after ForgetGrants when the dashboard changed them)
const std::shared_ptr<const PermissionGrants::Held>& GetGrants() const { return m_Grants; }
LWOOBJID GetGrantsCharacter() const { return m_GrantsCharacter; }
void SetGrants(std::shared_ptr<const PermissionGrants::Held> grants, LWOOBJID characterId) { m_Grants = std::move(grants); m_GrantsCharacter = characterId; }
void ForgetGrants() { m_Grants.reset(); }
private:
uint32_t m_AccountID;
std::string m_Username;
@@ -78,6 +87,8 @@ private:
uint64_t m_MuteExpire;
std::chrono::steady_clock::time_point m_LastMuteCheck{};
std::vector<CaughtFunness> m_CaughtFunness{};
std::shared_ptr<const PermissionGrants::Held> m_Grants{};
LWOOBJID m_GrantsCharacter{};
};
#endif // USER_H

View File

@@ -10,6 +10,8 @@
#include "ChatPackets.h"
#include "WorldMigration.h"
#include <algorithm>
#include <ctime>
#include <iomanip>
#include <ranges>
#include <set>
@@ -26,6 +28,8 @@
#include "dConfig.h"
#include "SlashCommandLevels.h"
#include "Permissions.h"
#include "PermissionGrants.h"
#include "PermissionGrantsLoader.h"
#include "Character.h"
#include "ChatPackets.h"
#include "PlayerManager.h"
@@ -123,19 +127,47 @@ std::optional<CommandTarget> SlashCommandHandler::FindTarget(const std::string&
return target;
}
AccountRules::eManageDenial SlashCommandHandler::TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule) {
AccountRules::eManageDenial SlashCommandHandler::TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule,
const PermissionGrants::Held* actorGrants) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eAccountAction;
const auto denial = [&](eAccountAction action) { return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, action, nullptr, actorGrants); };
switch (rule) {
case eTargetRule::NONE: return AccountRules::eManageDenial::NONE;
case eTargetRule::OTHERS:
if (actorAccountId != 0 && actorAccountId == targetAccountId) return AccountRules::eManageDenial::NONE;
return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS);
case eTargetRule::ITEMS: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::ITEMS);
case eTargetRule::MODERATION: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::MODERATION);
default: return AccountRules::ManageDenialNow(actorLevel, actorAccountId, targetLevel, targetAccountId, AccountRules::eAccountAction::TOOLS);
return denial(eAccountAction::TOOLS);
case eTargetRule::ITEMS: return denial(eAccountAction::ITEMS);
case eTargetRule::MODERATION: return denial(eAccountAction::MODERATION);
default: return denial(eAccountAction::TOOLS);
}
}
const PermissionGrants::Held* SlashCommandHandler::GrantsOf(Entity* player) {
auto* character = player ? player->GetCharacter() : nullptr;
auto* user = character ? character->GetParentUser() : nullptr;
if (!user) return nullptr;
if (!user->GetGrants() || user->GetGrantsCharacter() != character->GetID()) {
user->SetGrants(PermissionGrants::Load(user->GetAccountID(), character->GetID()), character->GetID());
}
return user->GetGrants().get();
}
PermissionGrants::Command SlashCommandHandler::GrantRules(const Command& command) {
return { command.name, static_cast<uint8_t>(GetRequiredLevel(command)), static_cast<uint8_t>(command.minLevel.value_or(eGameMasterLevel::CIVILIAN)),
command.fixedLevel, Permissions::Find(command.dashboardPermission) ? command.dashboardPermission : "" };
}
bool SlashCommandHandler::MayUse(Entity* player, const Command& command) {
if (!player) return false;
auto* character = player->GetCharacter();
auto* user = character ? character->GetParentUser() : nullptr;
const auto playerLevel = static_cast<uint8_t>(player->GetGMLevel());
// Denies never apply to a GM 9 account, even while it plays at a lower level
const auto accountLevel = std::max(playerLevel, static_cast<uint8_t>(user ? user->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN));
return PermissionGrants::MayUseCommand(playerLevel, accountLevel, GrantRules(command), GrantsOf(player), static_cast<int64_t>(std::time(nullptr)));
}
std::string SlashCommandHandler::TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command) {
if (denial == AccountRules::eManageDenial::NONE) return "";
using SlashCommandLevels::eTargetRule;
@@ -146,7 +178,7 @@ std::string SlashCommandHandler::TargetRefusal(AccountRules::eManageDenial denia
bool SlashCommandHandler::MayActOn(Entity* actor, const SystemAddress& sysAddr, const CommandTarget& target, SlashCommandLevels::eTargetRule rule, std::string_view command) {
if (!actor) return false;
const auto denial = TargetDenial(static_cast<uint8_t>(actor->GetGMLevel()), AccountOf(actor), target.gmLevel, target.accountId, rule);
const auto denial = TargetDenial(static_cast<uint8_t>(actor->GetGMLevel()), AccountOf(actor), target.gmLevel, target.accountId, rule, GrantsOf(actor));
if (denial == AccountRules::eManageDenial::NONE) return true;
ChatPackets::SendSystemMessage(sysAddr, GeneralUtils::UTF8ToUTF16(TargetRefusal(denial, rule, command)));
return false;
@@ -249,9 +281,12 @@ void SlashCommandHandler::HandleChatCommand(const std::u16string& chat, Entity*
if (commandItr != RegisteredCommands.end()) {
auto& [alias, commandHandle] = *commandItr;
const auto requiredLevel = GetRequiredLevel(commandHandle);
if (entity->GetGMLevel() >= requiredLevel) {
if (MayUse(entity, commandHandle)) {
if (requiredLevel > eGameMasterLevel::CIVILIAN) Database::Get()->InsertSlashCommandUsage(entity->GetObjectID(), input);
commandHandle.handle(entity, sysAddr, args);
} else if (entity->GetGMLevel() >= requiredLevel) {
// The level allows it, but a deny on the dashboard took it away
error = "You may not use \"" + command + "\": it was taken away from you";
} else if (entity->GetGMLevel() != eGameMasterLevel::CIVILIAN) {
error = "You are not high enough GM level to use \"" + command + "\"";
}
@@ -282,7 +317,7 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st
std::map<std::string, Command> accessibleCommands;
for (const auto& [commandName, command] : CommandInfos) {
if (SlashCommandHandler::GetRequiredLevel(command) <= entity->GetGMLevel()) {
if (SlashCommandHandler::MayUse(entity, command)) {
accessibleCommands.emplace(commandName, command);
}
}
@@ -312,7 +347,7 @@ void GMZeroCommands::Help(Entity* entity, const SystemAddress& sysAddr, const st
}
const auto it = RegisteredCommands.find(trimmedArgs);
if (it != RegisteredCommands.end() && entity->GetGMLevel() >= SlashCommandHandler::GetRequiredLevel(it->second)) {
if (it != RegisteredCommands.end() && SlashCommandHandler::MayUse(entity, it->second)) {
const auto& command = it->second;
feedback << "----- " << it->first << " Info -----\n";
feedback << command.info << "\n";

View File

@@ -11,6 +11,7 @@
#include "dCommonVars.h"
#include "AccountRules.h"
#include "SlashCommandLevels.h"
#include "PermissionGrants.h"
#include <functional>
#include <optional>
#include <string>
@@ -80,7 +81,8 @@ namespace SlashCommandHandler {
* their own level only with manage_equal_rank, and on themselves only with the self_* permission for the rule
* (OTHERS: on themselves always, as before).
*/
AccountRules::eManageDenial TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule);
AccountRules::eManageDenial TargetDenial(uint8_t actorLevel, uint32_t actorAccountId, uint8_t targetLevel, uint32_t targetAccountId, SlashCommandLevels::eTargetRule rule,
const PermissionGrants::Held* actorGrants = nullptr);
// Why a command may not be used on someone, for the chat; empty when it may
std::string TargetRefusal(AccountRules::eManageDenial denial, SlashCommandLevels::eTargetRule rule, std::string_view command);
@@ -93,6 +95,20 @@ namespace SlashCommandHandler {
// The account of the player using a command (0 if unknown)
uint32_t AccountOf(Entity* player);
/**
* The permission grants of a player's account and logged-in character (PermissionGrants.h), loaded from the database
* the first time they are needed and kept on the User until the dashboard changes them (DashboardActions). nullptr:
* not a player.
*/
const PermissionGrants::Held* GrantsOf(Entity* player);
// Whether a player may use a command now: its level, or a grant for it (the command, a command group, or the
// dashboard permission it follows), unless a deny takes it away (PermissionGrants::MayUseCommand)
bool MayUse(Entity* player, const Command& command);
// The command as the grant rules see it: its name, level now, floor, and the dashboard permission it follows
PermissionGrants::Command GrantRules(const Command& command);
};
namespace GMZeroCommands {

View File

@@ -267,3 +267,117 @@ TEST_F(SlashCommandCatalogTest, NewServerFollowsThePermissions) {
Game::config->SetDatabaseValues({}, {});
EXPECT_EQ(static_cast<uint8_t>(SlashCommandHandler::GetRequiredLevel(*SlashCommandHandler::FindCommand("mute"))), Permissions::Level("accounts_mute"));
}
#include "Character.h"
#include "Entity.h"
#include "PermissionGrants.h"
#include "User.h"
// Grants and denies from the dashboard (PermissionGrants.h), as a player using commands sees them
class SlashCommandGrantsTest : public GameDependenciesTest {
protected:
static constexpr LWOOBJID CHARACTER_ID = 42;
void SetUp() override {
SetUpDependencies();
static bool started = false;
if (!started) SlashCommandHandler::Startup();
started = true;
user = std::make_unique<User>(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key");
character = std::make_unique<Character>(CHARACTER_ID, user.get());
entity = std::make_unique<Entity>(1, info);
entity->SetCharacter(character.get());
character->SetEntity(entity.get());
}
void TearDown() override {
entity->SetCharacter(nullptr);
entity.reset();
character.reset();
user.reset();
TearDownDependencies();
}
// Play at a GM level (the account's is at least that) with these grants loaded for the character
void Play(eGameMasterLevel level, std::vector<PermissionGrants::Rule> rules, eGameMasterLevel accountLevel = eGameMasterLevel::CIVILIAN) {
user->SetMaxGMLevel(std::max(level, accountLevel));
entity->SetGMLevel(level);
auto held = std::make_shared<PermissionGrants::Held>();
held->rules = std::move(rules);
user->SetGrants(held, CHARACTER_ID);
}
bool MayUse(const std::string& alias) {
const auto* command = SlashCommandHandler::FindCommand(alias);
EXPECT_NE(command, nullptr) << alias;
return command && SlashCommandHandler::MayUse(entity.get(), *command);
}
std::unique_ptr<User> user;
std::unique_ptr<Character> character;
std::unique_ptr<Entity> entity;
};
TEST_F(SlashCommandGrantsTest, GrantsLetAPlayerUseACommand) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::MODERATOR, {});
EXPECT_FALSE(MayUse("spawn"));
EXPECT_TRUE(MayUse("kick")); // accounts_kick: GM 2
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
EXPECT_FALSE(MayUse("gmadditem"));
// A grant of a dashboard permission covers the commands that follow it
Play(eGameMasterLevel::CIVILIAN, { { eKind::PERMISSION, "accounts_kick", false, 0 } });
EXPECT_TRUE(MayUse("kick"));
// Every command up to a GM level
Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND_GROUP, "8", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
EXPECT_TRUE(MayUse("gmadditem"));
// ...but never /execute below its floor
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND_GROUP, "9", false, 0 }, { eKind::COMMAND, "execute", false, 0 } });
EXPECT_FALSE(MayUse("execute"));
// Past its expiry a grant does nothing
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 1 } });
EXPECT_FALSE(MayUse("spawn"));
}
TEST_F(SlashCommandGrantsTest, DeniesTakeCommandsAwayExceptFromOperators) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 }, { eKind::PERMISSION, "accounts_kick", true, 0 } });
EXPECT_FALSE(MayUse("spawn"));
EXPECT_FALSE(MayUse("kick"));
EXPECT_TRUE(MayUse("gmadditem"));
// A GM 9 account playing at GM 8 keeps everything
Play(eGameMasterLevel::DEVELOPER, { { eKind::COMMAND, "spawn", true, 0 } }, eGameMasterLevel::OPERATOR);
EXPECT_TRUE(MayUse("spawn"));
// A player command can be taken from a player
Play(eGameMasterLevel::CIVILIAN, { { eKind::COMMAND, "pvp", true, 0 } });
EXPECT_FALSE(MayUse("pvp"));
}
TEST_F(SlashCommandGrantsTest, ForgottenGrantsAreReadAgain) {
using PermissionGrants::eKind;
Play(eGameMasterLevel::MODERATOR, { { eKind::COMMAND, "spawn", false, 0 } });
EXPECT_TRUE(MayUse("spawn"));
// The dashboard changed them: read from the database again (none there)
user->ForgetGrants();
EXPECT_FALSE(MayUse("spawn"));
ASSERT_NE(user->GetGrants(), nullptr);
EXPECT_EQ(user->GetGrantsCharacter(), CHARACTER_ID);
}
TEST(SlashCommandPermissionTests, TargetRulesFollowGrants) {
using SlashCommandLevels::eTargetRule;
using AccountRules::eManageDenial;
using PermissionGrants::eKind;
ScopedConfig config({});
PermissionGrants::Held held;
held.rules = { { eKind::PERMISSION, "self_moderation", false, 0 }, { eKind::PERMISSION, "manage_equal_rank", false, 0 } };
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION), eManageDenial::SELF);
EXPECT_EQ(SlashCommandHandler::TargetDenial(8, 1, 8, 1, eTargetRule::MODERATION, &held), eManageDenial::NONE);
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 4, 2, eTargetRule::MODERATION, &held), eManageDenial::NONE);
// Never a higher GM level
EXPECT_EQ(SlashCommandHandler::TargetDenial(4, 1, 5, 2, eTargetRule::MODERATION, &held), eManageDenial::HIGHER_RANK);
}