menus/track-player.ts
21 KB · sha256:85863d59a75b4342545f5614ba2a035d35beaca0835e8e73296b8e4bb5a7f9fc
import { findTrack, listTracks } from "../models/Track";
import { getPlayerGroups } from "../models/Player";
import {
attemptJump,
currentRungIndex,
previewJump,
tracksPlayerIsOn,
} from "../lib/track-engine";
import { expressionFriendly } from "../lib/placeholders";
import {
GLASS,
BACK,
CLOSE,
PREV,
NEXT,
PAGE_INDICATOR,
ack,
escapeMM,
paginate,
runOnMain,
} from "./shared";
const PAGE_SIZE = 28; // 4 rows of 7 (rungs occupy slots 10..16, 19..25, 28..34, 37..43)
/**
* Entry point for `/ranks`. Routes based on how many tracks the player
* is currently sitting on:
* * 0 tracks -- show the selector with ALL tracks so they can browse.
* There's no "default" -- they aren't on anything yet.
* * 1 track -- skip the selector, open the rank ladder directly.
* * 2+ -- selector, sorted with the player's tracks first.
*/
export async function openPlayerTracksHub(viewer: Player): Promise<void> {
const onTracks = await tracksPlayerIsOn(viewer);
if (onTracks.length === 1) {
return openPlayerTrackRanks(viewer, onTracks[0].name);
}
return openPlayerTrackSelector(viewer, 0);
}
/**
* Selector across every track. The player's current rung on each track
* is shown so they can pick the one they care about; tracks they aren't
* on yet are still listed (greyed-out current-rank line) for
* discoverability.
*/
export async function openPlayerTrackSelector(
viewer: Player,
page: number,
): Promise<void> {
const uuid = String(viewer.getUniqueId());
const [allTracks, currentGroups] = await Promise.all([
listTracks(),
getPlayerGroups(uuid),
]);
// Sort: tracks the player is on first, then alphabetical. Keeps the
// useful options at the top of the list.
const annotated = allTracks
.map((t) => ({ t, idx: currentRungIndex(t.rungs, currentGroups) }))
.sort((a, b) => {
if ((a.idx >= 0) !== (b.idx >= 0)) return a.idx >= 0 ? -1 : 1;
return a.t.name.localeCompare(b.t.name);
});
const pageData = paginate(annotated, page, PAGE_SIZE);
runOnMain(() => {
const gui = rune.gui({
title: "<gradient:#06d6a0:#06b97e>Your Ranks</gradient>",
rows: 6,
});
gui.fill(GLASS());
gui.slot(
4,
rune
.item(bukkit.Material.COMPASS)
.name("<gradient:#06d6a0:#06b97e>Pick a track</gradient>")
.lore([
"<gray>Tracks you're currently on are",
"<gray>shown first. Click one to view",
"<gray>its ranks and rank up.",
])
.build(),
);
pageData.items.forEach(({ t, idx }, i) => {
const row = 1 + Math.floor(i / 7);
const col = 1 + (i % 7);
const slot = row * 9 + col;
const current = idx === -1 ? null : t.rungs[idx];
const onTrack = idx !== -1;
const lore = onTrack
? [
`<gray>Current rank: <green>${escapeMM(current!.group)}`,
`<gray>Rung <white>${idx + 1}<gray> of <white>${t.rungs.length}`,
"",
"<yellow>Click to view ranks",
]
: [
"<gray>You aren't on this track yet.",
`<gray>Has <white>${t.rungs.length}<gray> rung${t.rungs.length === 1 ? "" : "s"}`,
"",
"<yellow>Click to view ranks",
];
gui.slot(
slot,
rune
.item(onTrack ? bukkit.Material.LIME_BANNER : bukkit.Material.WHITE_BANNER)
.name(`<aqua>${escapeMM(t.name)}`)
.lore(lore)
.build(),
(e) => openPlayerTrackRanks(e.getWhoClicked() as Player, t.name),
);
});
if (pageData.hasPrev) {
gui.slot(45, PREV(pageData.page), (e) =>
openPlayerTrackSelector(e.getWhoClicked() as Player, pageData.page - 1),
);
}
gui.slot(49, PAGE_INDICATOR(pageData.page, pageData.total, "Tracks"));
if (pageData.hasNext) {
gui.slot(53, NEXT(pageData.page), (e) =>
openPlayerTrackSelector(e.getWhoClicked() as Player, pageData.page + 1),
);
}
gui.slot(50, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
/**
* The main player-facing rank ladder. Renders every rung as a concrete
* block with state-coloured material:
*
* green concrete + enchanted glow = current rank
* green concrete = previous (already attained) ranks
* yellow concrete = reachable (single rank-up OR a
* valid multi-rank jump from current)
* red concrete = locked (requirements / costs not met
* even cumulatively from current)
*
* Locked / reachable rungs show the cumulative cost-to-jump in their
* lore so the player knows what the click would cost. Clicking a
* reachable / locked rung walks into a confirmation screen showing the
* grand total; clicking past or current ranks just acks.
*/
export async function openPlayerTrackRanks(
viewer: Player,
trackName: string,
page = 0,
): Promise<void> {
const uuid = String(viewer.getUniqueId());
const [track, currentGroups] = await Promise.all([
findTrack(trackName),
getPlayerGroups(uuid),
]);
if (!track) {
ack(viewer, `<red>Track <white>${escapeMM(trackName)}<red> not found.`);
return openPlayerTrackSelector(viewer, 0);
}
if (track.rungs.length === 0) {
ack(viewer, `<yellow>Track <white>${escapeMM(track.name)}<yellow> has no ranks yet.`);
return openPlayerTrackSelector(viewer, 0);
}
const currentIdx = currentRungIndex(track.rungs, currentGroups);
const pageData = paginate(track.rungs, page, PAGE_SIZE);
const pageOffset = pageData.page * PAGE_SIZE;
// Pre-compute a jump preview for every rung above the current one
// so we only walk the rungs array once. previewJump itself is O(n)
// in jump distance + handler-check time, but with N ~= 26 in
// typical prison-rank deployments this is fine on click.
const previews = new Map<number, ReturnType<typeof previewJump>>();
for (let i = currentIdx + 1; i < track.rungs.length; i++) {
previews.set(i, previewJump(viewer, track, currentIdx, i));
}
runOnMain(() => {
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>Ranks · ${escapeMM(track.name)}</gradient>`,
rows: 6,
});
gui.fill(GLASS());
const currentLabel = currentIdx === -1
? "<gray>Not on this track yet"
: `<gray>Current: <green>${escapeMM(track.rungs[currentIdx].group)}<gray> (rung <white>${currentIdx + 1}<gray>/<white>${track.rungs.length}<gray>)`;
gui.slot(
4,
rune
.item(bukkit.Material.PLAYER_HEAD)
.skullOwner(viewer)
.name(`<aqua>${escapeMM(viewer.getName())}`)
.lore([
currentLabel,
"",
"<gray>Click a <green>green<gray> block to see info.",
"<gray>Click a <yellow>yellow<gray> block to rank up.",
"<gray>Click a <red>red<gray> block to see what's missing.",
])
.build(),
);
pageData.items.forEach((rung, i) => {
const idx = pageOffset + i;
const row = 1 + Math.floor(i / 7);
const col = 1 + (i % 7);
const slot = row * 9 + col;
const isPast = idx < currentIdx;
const isCurrent = idx === currentIdx;
const preview = previews.get(idx);
const isReachable = preview?.reachable === true;
const distance = idx - currentIdx;
let material: Material;
let prefix: string;
let lore: string[];
if (isCurrent) {
material = bukkit.Material.LIME_CONCRETE;
prefix = "<green>★ ";
lore = [
`<gray>Rung <white>${idx + 1}<gray> of <white>${track.rungs.length}`,
"<green>This is your current rank.",
"",
"<dark_gray>Click for details",
];
} else if (isPast) {
material = bukkit.Material.LIME_CONCRETE;
prefix = "<green>";
lore = [
`<gray>Rung <white>${idx + 1}<gray> of <white>${track.rungs.length}`,
"<dark_green>Already attained.",
"",
"<dark_gray>Click for details",
];
} else if (isReachable) {
material = bukkit.Material.YELLOW_CONCRETE;
prefix = "<yellow>";
lore = buildLockedLore(idx, distance, preview!);
} else {
material = bukkit.Material.RED_CONCRETE;
prefix = "<red>";
lore = buildLockedLore(idx, distance, preview!);
}
const builder = rune
.item(material)
.name(`${prefix}${idx + 1}. ${escapeMM(rung.group)}`)
.lore(lore);
if (isCurrent) builder.glow();
gui.slot(slot, builder.build(), (e) => {
const p = e.getWhoClicked() as Player;
// Reachable (yellow) rungs are the only actionable case --
// every other click opens the info screen so the player can
// read what's going on. Lore is fine for hover but a screen
// is fine when the player misses the tooltip or wants more
// room than 4 capped lore lines.
if (!isCurrent && !isPast && preview?.reachable) {
return openJumpConfirm(p, track.name, idx);
}
return openRankInfo(p, track.name, idx);
});
});
if (pageData.hasPrev) {
gui.slot(45, PREV(pageData.page), (e) =>
openPlayerTrackRanks(e.getWhoClicked() as Player, track.name, pageData.page - 1),
);
}
gui.slot(
49,
PAGE_INDICATOR(
pageData.page,
pageData.total,
`${track.name}`,
),
);
if (pageData.hasNext) {
gui.slot(53, NEXT(pageData.page), (e) =>
openPlayerTrackRanks(e.getWhoClicked() as Player, track.name, pageData.page + 1),
);
}
gui.slot(48, BACK(), (e) =>
openPlayerTrackSelector(e.getWhoClicked() as Player, 0),
);
gui.slot(50, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
function buildLockedLore(
idx: number,
distance: number,
preview: ReturnType<typeof previewJump>,
): string[] {
const lore: string[] = [];
lore.push(`<gray>Rung <white>${idx + 1}`);
if (distance === 1) {
lore.push("<gray>Next rank up.");
} else {
lore.push(`<gray>Jump <white>+${distance}<gray> ranks from current.`);
}
lore.push("");
if (preview.costs.length === 0) {
lore.push("<dark_gray>(no cost)");
} else {
lore.push("<gold>Total cost:");
for (const c of preview.costs) {
const colour = c.check.ok ? "<white>" : "<red>";
const status = c.check.ok ? "" : " <dark_gray>(short)";
lore.push(` ${colour}${escapeMM(c.describe)}${status}`);
}
}
const failedReqs = preview.requirements.filter((r) => !r.pass);
if (failedReqs.length > 0) {
lore.push("");
lore.push("<red>Unmet requirements:");
// Cap at 4 to keep lore readable
for (const r of failedReqs.slice(0, 4)) {
lore.push(` <gray>•</gray> <white>${escapeMM(expressionFriendly(r.source))}`);
}
if (failedReqs.length > 4) {
lore.push(` <dark_gray>...and ${failedReqs.length - 4} more`);
}
}
lore.push("");
if (preview.reachable) {
lore.push(distance === 1 ? "<yellow>Click to rank up" : "<yellow>Click to jump");
} else {
lore.push("<dark_gray>Click for details");
}
return lore;
}
/**
* Read-only info screen for a single rung.
*
* Used when the player clicks ANY rung that isn't a one-click rank-up
* (i.e. past / current / locked). Locked rungs show the blocker and
* cumulative cost so the player understands what's keeping them out;
* past / current rungs show the rank's requirements + costs as
* historical record. There's a single Back button -- this is purely
* informational, no actions.
*/
export async function openRankInfo(
viewer: Player,
trackName: string,
idx: number,
): Promise<void> {
const uuid = String(viewer.getUniqueId());
const [track, currentGroups] = await Promise.all([
findTrack(trackName),
getPlayerGroups(uuid),
]);
if (!track || !track.rungs[idx]) {
ack(viewer, "<red>Rank not found.");
return openPlayerTracksHub(viewer);
}
const rung = track.rungs[idx];
const currentIdx = currentRungIndex(track.rungs, currentGroups);
const isPast = idx < currentIdx;
const isCurrent = idx === currentIdx;
// Locked ranks get a fresh preview so requirement state is up-to-date.
const preview = idx > currentIdx
? previewJump(viewer, track, currentIdx, idx)
: null;
runOnMain(() => {
const gui = rune.gui({
title: `<gradient:#06d6a0:#06b97e>Rank · ${escapeMM(rung.group)}</gradient>`,
rows: 6,
});
gui.fill(GLASS());
// --- Header tile mirrors the colour the player saw in the ladder.
let headerMaterial: Material;
let statusLine: string;
if (isCurrent) {
headerMaterial = bukkit.Material.LIME_CONCRETE;
statusLine = "<green>★ Current rank";
} else if (isPast) {
headerMaterial = bukkit.Material.LIME_CONCRETE;
statusLine = "<dark_green>Already attained";
} else if (preview?.reachable) {
headerMaterial = bukkit.Material.YELLOW_CONCRETE;
statusLine = "<yellow>Ready to rank up";
} else {
headerMaterial = bukkit.Material.RED_CONCRETE;
statusLine = "<red>Locked";
}
const headerBuilder = rune
.item(headerMaterial)
.name(`<aqua>${idx + 1}. ${escapeMM(rung.group)}`)
.lore([
`<gray>Track: <white>${escapeMM(track.name)}`,
`<gray>Rung <white>${idx + 1}<gray>/<white>${track.rungs.length}`,
"",
statusLine,
]);
if (isCurrent) headerBuilder.glow();
gui.slot(4, headerBuilder.build());
// --- Requirements panel (left side: slots 19 + 28 + 37).
const reqIcon = rung.requirements.length === 0
? rune
.item(bukkit.Material.WHITE_DYE)
.name("<gray>Requirements")
.lore(["<dark_gray>(none)"])
.build()
: (() => {
const lines: string[] = [];
if (preview) {
// Locked rank: show pass/fail status for THIS rung's reqs.
const myReqs = preview.requirements.filter((r) => r.rungIndex === idx);
for (const r of myReqs) {
const status = r.pass ? "<green>✓</green>" : "<red>✗</red>";
lines.push(`${status} <white>${escapeMM(expressionFriendly(r.source))}`);
}
} else {
// Past or current rank: requirements were satisfied to get
// here; render them with a green check as a historical note.
for (const r of rung.requirements) {
lines.push(`<green>✓</green> <white>${escapeMM(expressionFriendly(r))}`);
}
}
return rune
.item(bukkit.Material.WRITABLE_BOOK)
.name(`<yellow>Requirements <gray>(${rung.requirements.length})`)
.lore(lines)
.build();
})();
gui.slot(20, reqIcon);
// --- Costs panel (right side: slot 24).
const costsIcon = (() => {
if (rung.costs.length === 0) {
return rune
.item(bukkit.Material.GOLD_NUGGET)
.name("<gold>Cost")
.lore(["<dark_gray>(free)"])
.build();
}
const lines: string[] = [];
if (preview) {
// For locked: show the cumulative jump cost (what they'd pay
// total if they rank up to here), AND flag whichever handler
// is short.
for (const c of preview.costs) {
const colour = c.check.ok ? "<white>" : "<red>";
const status = c.check.ok ? "<green>✓</green>" : "<red>✗</red>";
lines.push(`${status} ${colour}${escapeMM(c.describe)}`);
}
if (idx > currentIdx + 1) {
lines.push("");
lines.push("<dark_gray>Cumulative from current rank");
}
} else {
// Past / current: show this rung's per-cost handlers as paid.
// We don't have a stored "you paid X" history -- just describe
// the costs that gated this rung.
const seen = new Set<string>();
for (const cfg of rung.costs) {
const key = `${cfg.handler}:${cfg.amount}`;
if (seen.has(key)) continue;
seen.add(key);
// describeFailure-style describe isn't available without the
// handler; render handler + amount as a cheap fallback.
const amt = typeof cfg.amount === "number" ? ` ${cfg.amount}` : "";
lines.push(`<gray>•</gray> <white>${escapeMM(cfg.handler)}${amt}`);
}
}
return rune
.item(bukkit.Material.GOLD_INGOT)
.name(`<gold>Cost <gray>(${rung.costs.length})`)
.lore(lines)
.build();
})();
gui.slot(24, costsIcon);
// --- Blocker call-out (locked only).
if (preview && preview.blocker && preview.blocker.rungIndex !== idx) {
gui.slot(
31,
rune
.item(bukkit.Material.BARRIER)
.name("<red>Blocked by an earlier rank")
.lore([
`<gray>Stuck at rung <white>${preview.blocker.rungIndex + 1}<gray>:`,
` <red>${escapeMM(track.rungs[preview.blocker.rungIndex].group)}`,
"",
"<gray>Earn that one first before",
"<gray>jumping to this rank.",
])
.build(),
);
} else if (preview && !preview.reachable) {
gui.slot(
31,
rune
.item(bukkit.Material.BARRIER)
.name("<red>Can't rank up yet")
.lore([
"<gray>One or more requirements",
"<gray>or costs above aren't met.",
])
.build(),
);
} else if (preview?.reachable) {
gui.slot(
31,
rune
.item(bukkit.Material.LIME_DYE)
.name("<green>Ready when you are")
.lore([
"<gray>Go back and click the",
"<gray>yellow block to rank up.",
])
.build(),
);
}
gui.slot(48, BACK(), (e) =>
openPlayerTrackRanks(e.getWhoClicked() as Player, track.name),
);
gui.slot(50, CLOSE(), (e) => e.getWhoClicked().closeInventory());
gui.open(viewer);
});
}
/**
* Confirmation step for any rank-up / jump action. Big-action gate
* before the player loses money / XP / etc. Shows the grand total and
* the exact destination so they know what they're signing up for.
*/
function openJumpConfirm(
viewer: Player,
trackName: string,
targetIdx: number,
): void {
// Build the question + run the preview synchronously here so the
// confirm dialog reflects the same data the click saw -- no race
// with auto-promote ticks.
(async () => {
const uuid = String(viewer.getUniqueId());
const [track, currentGroups] = await Promise.all([
findTrack(trackName),
getPlayerGroups(uuid),
]);
if (!track || !track.rungs[targetIdx]) {
ack(viewer, "<red>That rank no longer exists.");
return openPlayerTrackSelector(viewer, 0);
}
const currentIdx = currentRungIndex(track.rungs, currentGroups);
const preview = previewJump(viewer, track, currentIdx, targetIdx);
if (!preview.reachable) {
ack(viewer, "<red>You can no longer reach that rank.");
return openPlayerTrackRanks(viewer, trackName);
}
const targetGroup = track.rungs[targetIdx].group;
const distance = targetIdx - currentIdx;
const fromLabel = currentIdx === -1 ? "(no rank)" : track.rungs[currentIdx].group;
const costSummary = preview.costs.length === 0
? "no cost"
: preview.costs.map((c) => c.describe).join(", ");
const question =
distance === 1
? `Rank up to ${targetGroup}?`
: `Jump ${distance} ranks → ${targetGroup}?`;
runOnMain(() => {
const gui = rune.gui({ title: `<dark_red>${question}`, rows: 3 });
gui.fill(GLASS());
gui.slot(
4,
rune
.item(bukkit.Material.PAPER)
.name(`<gradient:#06d6a0:#06b97e>${escapeMM(question)}</gradient>`)
.lore([
`<gray>From: <white>${escapeMM(fromLabel)}`,
`<gray>To: <green>${escapeMM(targetGroup)}`,
"",
`<gold>Cost: <white>${escapeMM(costSummary)}`,
])
.build(),
);
gui.slot(
11,
rune
.item(bukkit.Material.LIME_CONCRETE)
.name("<green>Confirm")
.lore(["<gray>Spend the cost and rank up."])
.build(),
async (e) => {
const p = e.getWhoClicked() as Player;
p.closeInventory();
const result = await attemptJump(p, trackName, targetIdx);
if (result.ok) {
ack(
p,
`<green>Promoted to <white>${escapeMM(result.to)}<green>!`,
);
} else {
const detail = result.reason === "blocked"
? result.detail ?? "blocked"
: result.reason;
ack(p, `<red>${escapeMM(detail)}`);
}
return openPlayerTrackRanks(p, trackName);
},
);
gui.slot(
15,
rune
.item(bukkit.Material.RED_CONCRETE)
.name("<red>Cancel")
.lore(["<gray>Back to the rank list."])
.build(),
(e) => openPlayerTrackRanks(e.getWhoClicked() as Player, trackName),
);
gui.open(viewer);
});
})().catch((e) => {
ack(viewer, `<red>${escapeMM((e as Error)?.message ?? String(e))}`);
});
}