menus/users.ts
16 KB · sha256:1e705d928d4106f923e931a8c307468341eb0984130689d2f2d773f082a4807b
import {
findPlayerByUuid,
getPlayerGroups,
listPlayers,
resolvePrefix,
resolveSuffix,
setGroup,
addGroup,
removeGroup,
addPermission,
setPrefix,
setSuffix,
} from "../models/Player";
import { listGroupNames, findGroup } from "../models/Group";
import { listTrackNames } from "../models/Track";
import { attemptDemote, attemptPromote, describeFailure } from "../lib/track-engine";
import { refreshOne } from "../lib/attachments";
import { onlineByUuid, resolveByName } from "../lib/identity";
import { promote as ladderPromote, demote as ladderDemote } from "../models/Track";
import {
GLASS,
BACK,
CLOSE,
PREV,
NEXT,
PAGE_INDICATOR,
paginate,
prompt,
runOnMain,
ack,
escapeMM,
} from "./shared";
import { openAdminMenu } from "./index";
const PAGE_SIZE = 36;
export async function openUsersMenu(viewer: Player, page = 0): Promise<void> {
const all = await listPlayers(500, 0);
const pageData = paginate(all, page, PAGE_SIZE);
runOnMain(() => {
const gui = rune.gui({
title: "<gradient:#9b87f5:#5b3df5>Ward · Users</gradient>",
rows: 6,
});
gui.slot(
4,
rune
.item(bukkit.Material.SPYGLASS)
.name("<yellow>Find Player")
.lore([
"<gray>Type a name in chat to open that",
"<gray>player's detail card directly.",
"",
"<yellow>Click to search",
])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const name = await prompt(p, "Type a player name");
if (!name) return;
const id = await resolveByName(name);
if (!id) {
runOnMain(() =>
p.sendMessage(rune.mm(`<red>Unknown player: ${escapeMM(name)}`)),
);
return openUsersMenu(p, page);
}
return openUserDetail(p, id.uuid);
},
);
// Body: player heads
pageData.items.forEach((doc, i) => {
const slot = 9 + i;
gui.slot(
slot,
rune
.item(bukkit.Material.PLAYER_HEAD)
.skullOwner(doc.uuid)
.name(`<gold>${escapeMM(doc.username || doc.uuid)}`)
.lore([
`<gray>Primary: <white>${escapeMM(doc.primaryGroup || "(none)")}`,
`<gray>Nodes: <white>${doc.nodes.length}`,
`<gray>UUID: <dark_gray>${doc.uuid}`,
"",
"<yellow>Click to manage",
])
.build(),
(e) => openUserDetail(e.getWhoClicked() as Player, doc.uuid),
);
});
// Bottom nav
if (pageData.hasPrev) {
gui.slot(45, PREV(pageData.page), (e) =>
openUsersMenu(e.getWhoClicked() as Player, pageData.page - 1),
);
} else {
gui.slot(45, GLASS());
}
gui.slot(49, PAGE_INDICATOR(pageData.page, pageData.total, "Users"));
if (pageData.hasNext) {
gui.slot(53, NEXT(pageData.page), (e) =>
openUsersMenu(e.getWhoClicked() as Player, pageData.page + 1),
);
} else {
gui.slot(53, GLASS());
}
gui.slot(48, BACK(), (e) => openAdminMenu(e.getWhoClicked() as Player));
gui.slot(50, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
// ---------------------------------------------------------------------------
// User detail
// ---------------------------------------------------------------------------
export async function openUserDetail(
viewer: Player,
uuid: string,
): Promise<void> {
const doc = await findPlayerByUuid(uuid);
if (!doc) {
runOnMain(() =>
viewer.sendMessage(rune.mm("<red>That player has no Ward record.")),
);
return openUsersMenu(viewer, 0);
}
const [groups, prefix, suffix] = await Promise.all([
getPlayerGroups(uuid),
resolvePrefix(uuid),
resolveSuffix(uuid),
]);
runOnMain(() => {
const gui = rune.gui({
title: `<gradient:#9b87f5:#5b3df5>User · ${escapeMM(doc.username || uuid)}</gradient>`,
rows: 5,
});
gui.fill(GLASS());
// Header head card
gui.slot(
4,
rune
.item(bukkit.Material.PLAYER_HEAD)
.skullOwner(uuid)
.name(`<gold>${escapeMM(doc.username || uuid)}`)
.lore([
`<gray>Primary group: <white>${escapeMM(doc.primaryGroup || "(none)")}`,
`<gray>Groups: <white>${groups.length ? groups.map(escapeMM).join(", ") : "<dark_gray>(none)"}`,
`<gray>Prefix: <reset>${prefix || "<dark_gray>(none)"}`,
`<gray>Suffix: <reset>${suffix || "<dark_gray>(none)"}`,
`<gray>Nodes: <white>${doc.nodes.length}`,
`<gray>UUID: <dark_gray>${doc.uuid}`,
])
.build(),
);
// Action row 1 (slots 19-25)
gui.slot(
19,
rune
.item(bukkit.Material.NETHER_STAR)
.name("<yellow>Set Primary Group")
.lore([
"<gray>Pick a group to make this",
"<gray>player's primary group.",
])
.build(),
(e) =>
openGroupPicker(
e.getWhoClicked() as Player,
"Pick primary group",
async (g) => {
await setGroup(uuid, g);
await refreshOne(uuid);
return openUserDetail(e.getWhoClicked() as Player, uuid);
},
),
);
gui.slot(
20,
rune
.item(bukkit.Material.LIME_DYE)
.name("<green>Add Group")
.lore([
"<gray>Inherit another group",
"<gray>(in addition to current).",
])
.build(),
(e) =>
openGroupPicker(
e.getWhoClicked() as Player,
"Pick a group to add",
async (g) => {
await addGroup(uuid, g);
await refreshOne(uuid);
return openUserDetail(e.getWhoClicked() as Player, uuid);
},
),
);
gui.slot(
21,
rune
.item(bukkit.Material.RED_DYE)
.name("<red>Remove Group")
.lore(["<gray>Drop one of this player's", "<gray>inherited groups."])
.build(),
(e) =>
openValuePicker(
e.getWhoClicked() as Player,
"Pick group to remove",
groups,
async (g) => {
await removeGroup(uuid, g);
await refreshOne(uuid);
return openUserDetail(e.getWhoClicked() as Player, uuid);
},
),
);
gui.slot(
22,
rune
.item(bukkit.Material.NAME_TAG)
.name("<yellow>Set Prefix")
.lore([
"<gray>Current: <reset>" + (prefix || "<dark_gray>(none)"),
"",
"<gray>Click to enter a new prefix",
"<gray>(MiniMessage supported).",
])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const text = await prompt(p, "Type the new prefix (MiniMessage)");
if (text != null) {
await setPrefix(uuid, text);
await refreshOne(uuid);
ack(p, "<green>Prefix updated.");
}
return openUserDetail(p, uuid);
},
);
gui.slot(
23,
rune
.item(bukkit.Material.NAME_TAG)
.name("<yellow>Set Suffix")
.lore([
"<gray>Current: <reset>" + (suffix || "<dark_gray>(none)"),
"",
"<gray>Click to enter a new suffix",
"<gray>(MiniMessage supported).",
])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const text = await prompt(p, "Type the new suffix (MiniMessage)");
if (text != null) {
await setSuffix(uuid, text);
await refreshOne(uuid);
ack(p, "<green>Suffix updated.");
}
return openUserDetail(p, uuid);
},
);
gui.slot(
24,
rune
.item(bukkit.Material.LADDER)
.name("<aqua>Promote")
.lore(["<gray>Move this player up one rung", "<gray>on a track."])
.build(),
(e) =>
openTrackPicker(
e.getWhoClicked() as Player,
"Pick a track to promote on",
async (track) => {
const clicker = e.getWhoClicked() as Player;
const online = onlineByUuid(uuid);
if (!online) {
const fallback = await ladderPromote(uuid, track);
if (fallback.ok) {
await refreshOne(uuid);
ack(clicker, `<yellow>(offline) <green>${escapeMM(doc.username || uuid)} → ${escapeMM(fallback.to)}`);
} else {
ack(clicker, `<red>${escapeMM(fallback.reason)}`);
}
return openUserDetail(clicker, uuid);
}
const res = await attemptPromote(online, track, groups);
if (res.ok) {
ack(clicker, `<green>${escapeMM(doc.username || uuid)} → ${escapeMM(res.to)}`);
} else if (res.failure) {
clicker.sendMessage(
rune.mm(
`<red>Cannot promote <white>${escapeMM(doc.username || uuid)}</white>:<newline>${describeFailure(res.failure)}`,
),
);
} else {
ack(clicker, `<red>${escapeMM(res.detail ?? res.reason)}`);
}
return openUserDetail(clicker, uuid);
},
),
);
gui.slot(
25,
rune
.item(bukkit.Material.LADDER)
.name("<red>Demote")
.lore(["<gray>Move this player down one rung", "<gray>on a track."])
.build(),
(e) =>
openTrackPicker(
e.getWhoClicked() as Player,
"Pick a track to demote on",
async (track) => {
const clicker = e.getWhoClicked() as Player;
const online = onlineByUuid(uuid);
const res = online
? await attemptDemote(online, track)
: await ladderDemote(uuid, track);
if (res.ok) {
if (!online) await refreshOne(uuid);
ack(clicker, `<green>${escapeMM(doc.username || uuid)} → ${escapeMM(res.to || "(removed)")}`);
} else {
ack(clicker, `<red>${escapeMM(res.reason)}`);
}
return openUserDetail(clicker, uuid);
},
),
);
// Action row 2 (slots 28-32)
gui.slot(
28,
rune
.item(bukkit.Material.WRITABLE_BOOK)
.name("<yellow>Add Permission")
.lore(["<gray>Type the perm node in chat,", "<gray>then it's granted."])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const perm = await prompt(p, "Type the permission node to grant");
if (perm) {
await addPermission(uuid, perm);
await refreshOne(uuid);
ack(p, `<green>Granted <white>${escapeMM(perm)}`);
}
return openUserDetail(p, uuid);
},
);
gui.slot(
29,
rune
.item(bukkit.Material.BARRIER)
.name("<red>Clear All Nodes")
.lore([
`<gray>Strips all <white>${doc.nodes.length}<gray> nodes from`,
`<gray>this player. <red>Cannot be undone.`,
"",
"<yellow>Click to confirm",
])
.build(),
(e) =>
openConfirm(
e.getWhoClicked() as Player,
`Clear all nodes for ${doc.username || uuid}?`,
async () => {
const fresh = await findPlayerByUuid(uuid);
if (fresh) {
fresh.nodes.splice(0, fresh.nodes.length);
await fresh.save();
await refreshOne(uuid);
}
ack(e.getWhoClicked() as Player, "<green>Cleared.");
return openUserDetail(e.getWhoClicked() as Player, uuid);
},
() => openUserDetail(e.getWhoClicked() as Player, uuid),
),
);
// Bottom nav
gui.slot(36, BACK(), (e) => openUsersMenu(e.getWhoClicked() as Player, 0));
gui.slot(44, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
// ---------------------------------------------------------------------------
// Generic pickers used by user detail. Kept here because they're tuned
// to the user-screen back-stack; group/track menus have their own.
// ---------------------------------------------------------------------------
async function openGroupPicker(
viewer: Player,
title: string,
onPick: (groupName: string) => void | Promise<void>,
): Promise<void> {
const names = await listGroupNames();
const items = await Promise.all(
names.map(async (n) => {
const g = await findGroup(n);
return { name: n, weight: g?.weight ?? 0 };
}),
);
items.sort((a, b) => b.weight - a.weight);
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(items.length / 9) + 1));
const gui = rune.gui({
title: `<gradient:#9b87f5:#5b3df5>${title}</gradient>`,
rows,
});
items.slice(0, rows * 9 - 9).forEach((g, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.BOOK)
.name(`<gold>${escapeMM(g.name)}`)
.lore([
`<gray>Weight: <white>${g.weight}`,
"",
"<yellow>Click to pick",
])
.build(),
() => onPick(g.name),
);
});
gui.slot((rows - 1) * 9, BACK(), () => onPick(""));
gui.open(viewer);
});
}
async function openTrackPicker(
viewer: Player,
title: string,
onPick: (trackName: string) => void | Promise<void>,
): Promise<void> {
const names = await listTrackNames();
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(names.length / 9) + 1));
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>${title}</gradient>`,
rows,
});
names.forEach((n, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.COMPASS)
.name(`<aqua>${escapeMM(n)}`)
.lore(["<yellow>Click to pick"])
.build(),
() => onPick(n),
);
});
if (names.length === 0) {
gui.slot(
4,
rune
.item(bukkit.Material.BARRIER)
.name("<red>No tracks exist")
.lore(["<gray>Create one from /ward admin → Tracks"])
.build(),
);
}
gui.slot((rows - 1) * 9, BACK(), () => onPick(""));
gui.open(viewer);
});
}
function openValuePicker(
viewer: Player,
title: string,
values: string[],
onPick: (value: string) => void | Promise<void>,
): void {
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(values.length / 9) + 1));
const gui = rune.gui({
title: `<gradient:#9b87f5:#5b3df5>${title}</gradient>`,
rows,
});
if (values.length === 0) {
gui.slot(
4,
rune.item(bukkit.Material.BARRIER).name("<gray>(empty)").build(),
);
}
values.forEach((v, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.BOOK)
.name(`<white>${escapeMM(v)}`)
.lore(["<yellow>Click to pick"])
.build(),
() => onPick(v),
);
});
gui.slot((rows - 1) * 9, BACK(), () => onPick(""));
gui.open(viewer);
});
}
// ---------------------------------------------------------------------------
// Confirm dialog. 3 rows, big green check + big red X.
// ---------------------------------------------------------------------------
export function openConfirm(
viewer: Player,
question: string,
onYes: () => void | Promise<void>,
onNo: () => void | Promise<void>,
): void {
runOnMain(() => {
const gui = rune.gui({ title: `<red>${question}`, rows: 3 });
gui.fill(GLASS());
gui.slot(
11,
rune
.item(bukkit.Material.LIME_CONCRETE)
.name("<green>Confirm")
.lore(["<gray>Proceed with the action."])
.build(),
() => onYes(),
);
gui.slot(
15,
rune
.item(bukkit.Material.RED_CONCRETE)
.name("<red>Cancel")
.lore(["<gray>Back out -- no changes."])
.build(),
() => onNo(),
);
gui.open(viewer);
});
}