menus/tracks.ts
15 KB · sha256:60f852e46edfc20a8e561b46c0ddc0b28554572e2ced8d64ac9f62c16cf4e938
import {
listTracks,
findTrack,
createTrack,
deleteTrack,
appendToTrack,
insertIntoTrack,
removeFromTrack,
addRungRequirement,
removeRungRequirement,
addRungCost,
removeRungCost,
setRungAutoPromote,
type TransactionConfig,
} from "../models/Track";
import { listGroups } from "../models/Group";
import { compileExpr, ExprParseError } from "../lib/expr";
import { getHandler, listHandlers, missingPluginFor } from "../lib/transactions";
import {
GLASS,
BACK,
CLOSE,
PREV,
NEXT,
PAGE_INDICATOR,
paginate,
prompt,
runOnMain,
ack,
escapeMM,
} from "./shared";
import { openAdminMenu } from "./index";
import { openConfirm } from "./users";
const PAGE_SIZE = 28;
export async function openTracksMenu(viewer: Player, page = 0): Promise<void> {
const tracks = (await listTracks()).slice().sort((a, b) =>
a.name.localeCompare(b.name),
);
const pageData = paginate(tracks, page, PAGE_SIZE);
runOnMain(() => {
const gui = rune.gui({
title: "<gradient:#06d6a0:#06b97e>Ward · Tracks</gradient>",
rows: 6,
});
gui.slot(
4,
rune
.item(bukkit.Material.WRITABLE_BOOK)
.name("<green>Create Track")
.lore([
"<gray>Type a name in chat to create",
"<gray>a new (empty) track.",
])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const name = await prompt(p, "Name the new track");
if (!name) return openTracksMenu(p, page);
try {
await createTrack(name);
ack(p, `<green>Created track <white>${escapeMM(name)}`);
} catch (err) {
ack(p, `<red>${escapeMM((err as Error).message)}`);
}
return openTracksMenu(p, page);
},
);
pageData.items.forEach((t, i) => {
const row = 1 + Math.floor(i / 7);
const col = 1 + (i % 7);
const slot = row * 9 + col;
const ladder = t.rungs.map((r) => escapeMM(r.group)).join(" <gray>→ <white>");
const autoCount = t.rungs.filter((r) => r.autoPromote).length;
gui.slot(
slot,
rune
.item(bukkit.Material.COMPASS)
.name(`<aqua>${escapeMM(t.name)}`)
.lore([
`<gray>Rungs: <white>${t.rungs.length}`,
`<gray>Auto-promote: <white>${autoCount}`,
`<gray>Order: <white>${ladder || "<dark_gray>(empty)"}`,
"",
"<yellow>Click to manage",
])
.build(),
(e) => openTrackDetail(e.getWhoClicked() as Player, t.name),
);
});
if (pageData.hasPrev) {
gui.slot(45, PREV(pageData.page), (e) =>
openTracksMenu(e.getWhoClicked() as Player, pageData.page - 1),
);
} else {
gui.slot(45, GLASS());
}
gui.slot(49, PAGE_INDICATOR(pageData.page, pageData.total, "Tracks"));
if (pageData.hasNext) {
gui.slot(53, NEXT(pageData.page), (e) =>
openTracksMenu(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);
});
}
export async function openTrackDetail(
viewer: Player,
trackName: string,
): Promise<void> {
const t = await findTrack(trackName);
if (!t) {
runOnMain(() =>
viewer.sendMessage(rune.mm(`<red>Track not found: ${escapeMM(trackName)}`)),
);
return openTracksMenu(viewer, 0);
}
const rungs = t.rungs;
runOnMain(() => {
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>Track · ${escapeMM(t.name)}</gradient>`,
rows: 6,
});
gui.fill(GLASS());
gui.slot(
4,
rune
.item(bukkit.Material.COMPASS)
.name(`<aqua>${escapeMM(t.name)}`)
.lore([
`<gray>Rungs: <white>${rungs.length}`,
`<gray>Poll: <white>${t.pollSeconds ?? 30}s`,
"",
"<gray>Left-click a rung to remove it.",
"<gray>Right-click to edit it",
"<gray>(requirements / costs / auto).",
"<gray>Shift+right-click to insert",
"<gray>a new group BEFORE that rung.",
])
.build(),
);
const visible = rungs.slice(0, 27);
visible.forEach((r, i) => {
const slot = 18 + i;
const reqLabel = r.requirements.length ? `<yellow>${r.requirements.length} req` : "";
const costLabel = r.costs.length ? `<gold>${r.costs.length} cost` : "";
const autoLabel = r.autoPromote ? "<aqua>auto" : "";
const tags = [autoLabel, reqLabel, costLabel].filter(Boolean).join(" <dark_gray>·</dark_gray> ");
gui.slot(
slot,
rune
.item(r.autoPromote ? bukkit.Material.ENCHANTED_GOLDEN_APPLE : bukkit.Material.GOLDEN_HELMET)
.name(`<gold>${i + 1}. ${escapeMM(r.group)}`)
.lore([
tags || "<dark_gray>(no requirements / costs)",
"",
"<red>Left-click: remove from track",
"<yellow>Right-click: edit",
"<green>Shift+right-click: insert BEFORE",
])
.build(),
(e) => {
const p = e.getWhoClicked() as Player;
const click = String(e.getClick?.() ?? "");
if (click === "SHIFT_RIGHT") {
return openInsertPicker(p, t.name, i);
}
if (click === "RIGHT") {
return openRungEdit(p, t.name, i);
}
return openConfirm(
p,
`Remove ${r.group} from ${t.name}?`,
async () => {
await removeFromTrack(t.name, r.group);
ack(p, `<red>Removed <white>${escapeMM(r.group)}`);
return openTrackDetail(p, t.name);
},
() => openTrackDetail(p, t.name),
);
},
);
});
gui.slot(
45,
rune
.item(bukkit.Material.EMERALD)
.name("<green>Append Group")
.lore(["<gray>Add a group at the END of the track."])
.build(),
(e) => openAppendPicker(e.getWhoClicked() as Player, t.name),
);
gui.slot(
53,
rune
.item(bukkit.Material.TNT)
.name("<red>Delete Track")
.lore(["<gray>Permanently remove the track."])
.build(),
(e) =>
openConfirm(
e.getWhoClicked() as Player,
`Delete track ${t.name}?`,
async () => {
await deleteTrack(t.name);
ack(e.getWhoClicked() as Player, `<green>Deleted <white>${escapeMM(t.name)}`);
return openTracksMenu(e.getWhoClicked() as Player, 0);
},
() => openTrackDetail(e.getWhoClicked() as Player, t.name),
),
);
gui.slot(48, BACK(), (e) => openTracksMenu(e.getWhoClicked() as Player, 0));
gui.slot(50, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
export async function openRungEdit(
viewer: Player,
trackName: string,
index: number,
): Promise<void> {
const t = await findTrack(trackName);
if (!t || !t.rungs[index]) {
runOnMain(() => viewer.sendMessage(rune.mm(`<red>Rung not found.`)));
return openTrackDetail(viewer, trackName);
}
const rung = t.rungs[index];
runOnMain(() => {
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>${escapeMM(trackName)} · rung ${index + 1}</gradient>`,
rows: 6,
});
gui.fill(GLASS());
gui.slot(
4,
rune
.item(bukkit.Material.COMPASS)
.name(`<aqua>${index + 1}. ${escapeMM(rung.group)}`)
.lore([
`<gray>${rung.requirements.length} requirement${rung.requirements.length === 1 ? "" : "s"}`,
`<gray>${rung.costs.length} cost${rung.costs.length === 1 ? "" : "s"}`,
`<gray>autoPromote: <white>${rung.autoPromote}`,
])
.build(),
);
gui.slot(
13,
rune
.item(rung.autoPromote ? bukkit.Material.LIME_DYE : bukkit.Material.GRAY_DYE)
.name(rung.autoPromote ? "<green>Auto-promote ON" : "<gray>Auto-promote OFF")
.lore([
"<gray>When ON, Ward checks this rung's",
"<gray>requirements + costs every ~30s",
"<gray>and promotes eligible players.",
"",
"<yellow>Click to toggle",
])
.build(),
async (e) => {
await setRungAutoPromote(trackName, index, !rung.autoPromote);
return openRungEdit(e.getWhoClicked() as Player, trackName, index);
},
);
rung.requirements.slice(0, 7).forEach((r, i) => {
gui.slot(
18 + 1 + i,
rune
.item(bukkit.Material.PAPER)
.name(`<yellow>requirement ${i}`)
.lore([
`<white>${escapeMM(r)}`,
"",
"<red>Click to remove",
])
.build(),
async (e) => {
await removeRungRequirement(trackName, index, i);
return openRungEdit(e.getWhoClicked() as Player, trackName, index);
},
);
});
gui.slot(
18,
rune
.item(bukkit.Material.WRITABLE_BOOK)
.name("<green>+ Add requirement")
.lore([
"<gray>Expressions use {placeholder} syntax.",
"<gray>Example:",
" <white>{vault_eco_balance} >= 500",
" <white>{playtime_hours} > 5 AND",
" <white> {ward_group} == member",
"",
"<yellow>Click to type one in chat",
])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const expr = await prompt(p, "Type a requirement expression");
if (!expr) return openRungEdit(p, trackName, index);
try { compileExpr(expr); }
catch (parseErr) {
ack(p, `<red>${escapeMM(parseErr instanceof ExprParseError ? parseErr.message : String((parseErr as Error)?.message ?? parseErr))}`);
return openRungEdit(p, trackName, index);
}
await addRungRequirement(trackName, index, expr);
ack(p, `<green>Added`);
return openRungEdit(p, trackName, index);
},
);
rung.costs.slice(0, 7).forEach((c, i) => {
const handler = getHandler(c.handler);
const desc = handler ? handler.describe(c) : `<red>${escapeMM(c.handler)} (missing)`;
gui.slot(
36 + 1 + i,
rune
.item(bukkit.Material.GOLD_NUGGET)
.name(`<gold>cost ${i}`)
.lore([
`<white>${desc}`,
`<dark_gray>handler: ${escapeMM(c.handler)}`,
"",
"<red>Click to remove",
])
.build(),
async (e) => {
await removeRungCost(trackName, index, i);
return openRungEdit(e.getWhoClicked() as Player, trackName, index);
},
);
});
gui.slot(
36,
rune
.item(bukkit.Material.GOLD_INGOT)
.name("<green>+ Add cost")
.lore([
"<gray>Pick a handler, then amount.",
"",
"<yellow>Click to add",
])
.build(),
(e) => openCostHandlerPicker(e.getWhoClicked() as Player, trackName, index),
);
gui.slot(45, BACK(), (e) => openTrackDetail(e.getWhoClicked() as Player, trackName));
gui.slot(53, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
async function openCostHandlerPicker(
viewer: Player,
trackName: string,
rungIndex: number,
): Promise<void> {
const handlers = listHandlers();
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(handlers.length / 9) + 1));
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>Pick cost handler</gradient>`,
rows,
});
if (handlers.length === 0) {
gui.slot(
4,
rune
.item(bukkit.Material.BARRIER)
.name("<red>No handlers registered")
.lore([
"<gray>Defaults gated on missing plugins;",
"<gray>see /ward track handlers.",
])
.build(),
);
}
handlers.forEach((id, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.GOLD_INGOT)
.name(`<gold>${escapeMM(id)}`)
.lore(["<yellow>Click to pick"])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
const text = await prompt(p, `Amount for ${id}`);
if (text == null) return openRungEdit(p, trackName, rungIndex);
const amount = Number(text);
if (!Number.isFinite(amount) || amount < 0) {
ack(p, `<red>"${escapeMM(text)}" is not a non-negative number`);
return openRungEdit(p, trackName, rungIndex);
}
const cfg: TransactionConfig = { handler: id, amount };
await addRungCost(trackName, rungIndex, cfg);
ack(p, `<green>Added cost ${escapeMM(id)} ${amount}`);
return openRungEdit(p, trackName, rungIndex);
},
);
});
gui.slot((rows - 1) * 9, BACK(), (e) =>
openRungEdit(e.getWhoClicked() as Player, trackName, rungIndex),
);
gui.open(viewer);
});
}
async function openAppendPicker(viewer: Player, trackName: string): Promise<void> {
const groups = (await listGroups()).map((g) => g.name);
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(groups.length / 9) + 1));
const gui = rune.gui({
title: `<green>Append to ${escapeMM(trackName)}`,
rows,
});
groups.forEach((n, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.BOOK)
.name(`<white>${escapeMM(n)}`)
.lore(["<yellow>Click to append"])
.build(),
async (e) => {
await appendToTrack(trackName, n);
ack(
e.getWhoClicked() as Player,
`<green>Added <white>${escapeMM(n)}<green> to <white>${escapeMM(trackName)}`,
);
return openTrackDetail(e.getWhoClicked() as Player, trackName);
},
);
});
gui.slot((rows - 1) * 9, BACK(), (e) =>
openTrackDetail(e.getWhoClicked() as Player, trackName),
);
gui.open(viewer);
});
}
async function openInsertPicker(
viewer: Player,
trackName: string,
position: number,
): Promise<void> {
const groups = (await listGroups()).map((g) => g.name);
runOnMain(() => {
const rows = Math.max(2, Math.min(6, Math.ceil(groups.length / 9) + 1));
const gui = rune.gui({
title: `<green>Insert at #${position + 1} in ${escapeMM(trackName)}`,
rows,
});
groups.forEach((n, i) => {
gui.slot(
i,
rune
.item(bukkit.Material.BOOK)
.name(`<white>${escapeMM(n)}`)
.lore([`<yellow>Click to insert at position ${position + 1}`])
.build(),
async (e) => {
await insertIntoTrack(trackName, n, position);
ack(
e.getWhoClicked() as Player,
`<green>Inserted <white>${escapeMM(n)}`,
);
return openTrackDetail(e.getWhoClicked() as Player, trackName);
},
);
});
gui.slot((rows - 1) * 9, BACK(), (e) =>
openTrackDetail(e.getWhoClicked() as Player, trackName),
);
gui.open(viewer);
});
}