models/Player.ts
12 KB · sha256:d698ffecd5b83f923f69f028cb11c37143dcebc92d243404a25c0b23fffb14e2
import "reflect-metadata";
import { field, model, getModel, unique, type Doc } from "../db/odm";
import { NodeSchema } from "./Group";
import {
Node,
type NodeContext,
inferNodeType,
nodeKey,
parseAffix,
isActive,
sameContext,
inheritedGroupName,
} from "./Node";
import {
findGroup,
getDefaultGroup,
resolveGroupPermissions,
type GroupDoc,
} from "./Group";
@model("Player")
export class Player {
@unique()
@field({ type: String, required: true, lowercase: true, trim: true })
uuid!: string;
@field({ type: String, default: "", index: true })
username!: string;
@field({ type: String, default: "" })
primaryGroup!: string;
@field({ type: [NodeSchema], default: () => [] })
nodes!: Node[];
}
export type PlayerDoc = Doc<Player>;
const PlayerModel = getModel(Player);
export function findPlayerByUuid(uuid: string): Promise<PlayerDoc | null> {
return PlayerModel.findOne({ uuid: uuid.toLowerCase() }).exec();
}
export function findPlayerByName(username: string): Promise<PlayerDoc | null> {
return PlayerModel.findOne({
username: new RegExp(`^${escapeRegex(username)}$`, "i"),
}).exec();
}
export function listPlayers(limit = 100, skip = 0): Promise<PlayerDoc[]> {
return PlayerModel.find()
.sort({ username: 1 })
.skip(skip)
.limit(limit)
.exec();
}
export function searchPlayers(
partial: string,
limit = 25,
): Promise<PlayerDoc[]> {
return PlayerModel.find({
username: new RegExp(`^${escapeRegex(partial)}`, "i"),
})
.sort({ username: 1 })
.limit(limit)
.exec();
}
/** Group names the player is directly a member of (active nodes only). */
export async function getPlayerGroups(uuid: string): Promise<string[]> {
const p = await findPlayerByUuid(uuid);
if (!p) return [];
const groups = p.nodes
.filter((n) => n.type === "inheritance" && n.value && isActive(n))
.map((n) => inheritedGroupName(n.key))
.filter((x): x is string => x != null);
return groups;
}
/**
* Resolve a player's full effective permission set: default group fallback,
* every group (with parent chains), then direct nodes on top. Direct
* negations beat group grants. Honors context + expiry.
*/
export async function resolvePlayerPermissions(
uuid: string,
ctx: NodeContext = {},
): Promise<Map<string, boolean>> {
const p = await findPlayerByUuid(uuid);
const out = new Map<string, boolean>();
const groups = p ? await activeGroupNames(p) : [];
const effectiveGroups =
groups.length > 0 ? groups : await defaultGroupNameList();
// Resolve groups in ascending weight so higher-weight groups override.
const weighted = await sortGroupsByWeight(effectiveGroups);
for (const gname of weighted) {
const gperms = await resolveGroupPermissions(gname, ctx);
for (const [k, v] of gperms) out.set(k, v);
}
// Direct player nodes win over everything.
if (p) {
for (const n of p.nodes) {
if (n.type !== "permission" || !isActive(n)) continue;
if (!contextOk(n.context, ctx)) continue;
out.set(n.key, n.value);
}
}
return out;
}
/**
* OP bypass + Ward perm check. Matches the in-game command convention
* (lib/format.ts:requirePerm) so the web dashboard's auth gate doesn't
* surprise admins who can do anything in-game but get 403'd through HTTP.
*/
function isOpUuid(uuid: string): boolean {
try {
const javaUuid = rune.callStatic<{ toString(): string }>(
"java.util.UUID",
"fromString",
uuid,
);
const off = rune.bukkit.getOfflinePlayer(javaUuid);
return !!(off as { isOp?: () => boolean })?.isOp?.();
} catch {
return false;
}
}
/** Single permission check (fast-ish path; resolves the full set then reads). */
export async function hasPermission(
uuid: string,
perm: string,
ctx: NodeContext = {},
): Promise<boolean> {
if (isOpUuid(uuid)) return true;
const perms = await resolvePlayerPermissions(uuid, ctx);
if (perms.has(perm)) return perms.get(perm)!;
// Wildcard support: "a.b.c" matches a granted "a.b.*" or "a.*" or "*".
const parts = perm.split(".");
for (let i = parts.length; i >= 0; i--) {
const wild = [...parts.slice(0, i), "*"].join(".");
if (perms.has(wild)) return perms.get(wild)!;
}
return false;
}
/** Resolve the player's display prefix: highest-priority across self+groups. */
export async function resolvePrefix(
uuid: string,
ctx: NodeContext = {},
): Promise<string | null> {
return resolveAffix(uuid, "prefix", ctx);
}
export async function resolveSuffix(
uuid: string,
ctx: NodeContext = {},
): Promise<string | null> {
return resolveAffix(uuid, "suffix", ctx);
}
async function resolveAffix(
uuid: string,
type: "prefix" | "suffix",
ctx: NodeContext,
): Promise<string | null> {
const p = await findPlayerByUuid(uuid);
type Affix = { priority: number; text: string };
const considerInto = (nodes: Node[], current: Affix | null): Affix | null => {
let best = current;
for (const n of nodes) {
if (n.type !== type || !n.value || !isActive(n)) continue;
if (!contextOk(n.context, ctx)) continue;
const parsed = parseAffix(n.key);
if (!parsed) continue;
if (!best || parsed.priority > best.priority) best = parsed;
}
return best;
};
let best: Affix | null = null;
// Player's own affixes first (count toward the same priority race).
if (p) best = considerInto(p.nodes, best);
const groups = p ? await activeGroupNames(p) : await defaultGroupNameList();
for (const gname of groups) {
const g = await findGroup(gname);
if (g) best = considerInto(g.nodes, best);
}
return best?.text ?? null;
}
// ==========================================================================
// MUTATIONS
// ==========================================================================
/** Get-or-create on join. Assigns the default group to brand-new players. */
export async function upsertPlayer(
uuid: string,
username: string,
): Promise<PlayerDoc> {
const lower = uuid.toLowerCase();
let p = await PlayerModel.findOne({ uuid: lower }).exec();
if (p) {
if (username && p.username !== username) {
p.username = username;
await p.save();
}
return p;
}
const def = await getDefaultGroup();
const nodes: Node[] = [];
if (def) {
nodes.push({
key: nodeKey.group(def.name),
value: true,
type: "inheritance",
context: {},
expiry: null,
priority: 0,
} as Node);
}
p = await PlayerModel.create({
uuid: lower,
username,
primaryGroup: def?.name ?? "",
nodes,
});
return p;
}
export async function addNode(
uuid: string,
key: string,
opts: {
value?: boolean;
context?: NodeContext;
expiry?: number | null;
priority?: number;
} = {},
): Promise<PlayerDoc | null> {
const p = await findPlayerByUuid(uuid);
if (!p) return null;
const type = inferNodeType(key);
const context = opts.context ?? {};
const existing = p.nodes.find(
(n) => n.key === key && sameContext(n.context, context),
);
if (existing) {
existing.value = opts.value ?? true;
existing.expiry = opts.expiry ?? existing.expiry ?? null;
existing.priority = opts.priority ?? existing.priority;
} else {
p.nodes.push({
key,
value: opts.value ?? true,
type,
context,
expiry: opts.expiry ?? null,
priority: opts.priority ?? 0,
} as Node);
}
await p.save();
if (type === "inheritance") await recomputePrimaryGroup(p);
return p;
}
export async function removeNode(
uuid: string,
key: string,
context: NodeContext = {},
): Promise<PlayerDoc | null> {
const p = await findPlayerByUuid(uuid);
if (!p) return null;
p.nodes = p.nodes.filter(
(n) => !(n.key === key && sameContext(n.context, context)),
);
await p.save();
if (inferNodeType(key) === "inheritance") await recomputePrimaryGroup(p);
return p;
}
// Wrappers matching /pex user subcommands ----------------------------------
export const addPermission = (uuid: string, perm: string, ctx?: NodeContext) =>
addNode(uuid, perm, { value: true, context: ctx });
export const denyPermission = (uuid: string, perm: string, ctx?: NodeContext) =>
addNode(uuid, perm, { value: false, context: ctx });
export const removePermission = (
uuid: string,
perm: string,
ctx?: NodeContext,
) => removeNode(uuid, perm, ctx);
export const addGroup = (uuid: string, group: string) =>
addNode(uuid, nodeKey.group(group));
export const removeGroup = (uuid: string, group: string) =>
removeNode(uuid, nodeKey.group(group));
/** setgroup = replace all group memberships with a single group. */
export async function setGroup(
uuid: string,
group: string,
): Promise<PlayerDoc | null> {
const p = await findPlayerByUuid(uuid);
if (!p) return null;
p.nodes = p.nodes.filter((n) => n.type !== "inheritance");
p.nodes.push({
key: nodeKey.group(group),
value: true,
type: "inheritance",
context: {},
expiry: null,
priority: 0,
} as Node);
await p.save();
await recomputePrimaryGroup(p);
return p;
}
export async function setPrefix(
uuid: string,
text: string,
priority = 100,
): Promise<PlayerDoc | null> {
const p = await findPlayerByUuid(uuid);
if (!p) return null;
p.nodes = p.nodes.filter((n) => n.type !== "prefix");
if (text) {
p.nodes.push({
key: nodeKey.prefix(priority, text),
value: true,
type: "prefix",
context: {},
expiry: null,
priority,
} as Node);
}
await p.save();
return p;
}
export async function setSuffix(
uuid: string,
text: string,
priority = 100,
): Promise<PlayerDoc | null> {
const p = await findPlayerByUuid(uuid);
if (!p) return null;
p.nodes = p.nodes.filter((n) => n.type !== "suffix");
if (text) {
p.nodes.push({
key: nodeKey.suffix(priority, text),
value: true,
type: "suffix",
context: {},
expiry: null,
priority,
} as Node);
}
await p.save();
return p;
}
// ==========================================================================
// INTERNALS
// ==========================================================================
async function activeGroupNames(p: PlayerDoc): Promise<string[]> {
return p.nodes
.filter((n) => n.type === "inheritance" && n.value && isActive(n))
.map((n) => inheritedGroupName(n.key))
.filter((x): x is string => x != null);
}
async function defaultGroupNameList(): Promise<string[]> {
const def = await getDefaultGroup();
return def ? [def.name] : [];
}
async function sortGroupsByWeight(names: string[]): Promise<string[]> {
const docs = await Promise.all(names.map((n) => findGroup(n)));
const pairs = docs
.filter((g): g is GroupDoc => g != null)
.map((g) => ({ name: g.name, weight: g.weight }));
pairs.sort((a, b) => a.weight - b.weight); // ascending: higher weight applied last
return pairs.map((p) => p.name);
}
/** Primary group = highest-weight active group (LuckPerms semantics). */
async function recomputePrimaryGroup(p: PlayerDoc): Promise<void> {
const names = await activeGroupNames(p);
if (names.length === 0) {
const def = await getDefaultGroup();
p.primaryGroup = def?.name ?? "";
await p.save();
return;
}
const docs = (await Promise.all(names.map((n) => findGroup(n)))).filter(
(g): g is GroupDoc => g != null,
);
docs.sort((a, b) => b.weight - a.weight);
p.primaryGroup = docs[0]?.name ?? "";
await p.save();
}
function contextOk(nodeCtx: NodeContext = {}, queryCtx: NodeContext = {}) {
if (nodeCtx.server && nodeCtx.server !== queryCtx.server) return false;
if (nodeCtx.world && nodeCtx.world !== queryCtx.world) return false;
return true;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export { PlayerModel };