commands/ward-rankup.ts
7.0 KB · sha256:30a53fb4cd8b08cb6f31708f1c9aaa73b6956509799168d0e4b7ff3bfcb7fcd4
import { err, escapeMM, ok, raw, warn } from "../lib/format";
import { findTrack } from "../models/Track";
import { getPlayerGroups } from "../models/Player";
import {
attemptPromote,
currentRungIndex,
describeFailure,
previewJump,
tracksPlayerIsOn,
} from "../lib/track-engine";
import { expressionFriendly } from "../lib/placeholders";
import {
openPlayerTrackRanks,
openPlayerTracksHub,
} from "../menus/track-player";
import { trackSuggester } from "../lib/cache";
function isPlayer(sender: CommandSender): sender is Player {
return typeof (sender as Player).openInventory === "function";
}
/**
* `/ranks [track]` -- opens the player-facing rank ladder.
*
* With no argument: routes through the hub, which auto-skips the
* selector when the player is on exactly one track. With a track name:
* opens that track directly (useful for keybinds and `/r ranks prison`-style
* server menus).
*
* Intentionally has NO permission gate -- this is the player's own
* view of their own progress, no privileged data exposed.
*/
@Command("ranks", {
description: "View your rank progress and rank up",
aliases: ["mytracks"],
})
export class Ranks {
@Arg("track", { type: "string", suggest: trackSuggester, optional: true })
track?: string;
@Run
async run(ctx: CommandCtx) {
if (!isPlayer(ctx.sender)) {
err(ctx.sender, "Only players can use /ranks.");
return;
}
if (this.track) {
const t = await findTrack(this.track);
if (!t) {
err(ctx.sender, `unknown track: <white>${escapeMM(this.track)}`);
return;
}
return openPlayerTrackRanks(ctx.sender, t.name);
}
return openPlayerTracksHub(ctx.sender);
}
}
/**
* `/rankup [track]` -- single-step promotion on the player's track.
*
* No-arg form picks the only track the player is on; if they're on
* multiple, asks them to disambiguate (rather than silently picking
* one). With a track name, promotes on that specific track.
*
* NOTE: this command only PROMOTES. Players cannot demote themselves
* through any player-facing surface -- demotion stays an admin
* operation behind `/ward user <name> demote`.
*/
@Command("rankup", {
description: "Rank up one step on your track",
aliases: ["promote"],
})
export class RankUp {
@Arg("track", { type: "string", suggest: trackSuggester, optional: true })
track?: string;
@Run
async run(ctx: CommandCtx) {
if (!isPlayer(ctx.sender)) {
err(ctx.sender, "Only players can use /rankup.");
return;
}
const player = ctx.sender;
const trackName = await resolveTrack(player, this.track);
if (trackName == null) return;
const uuid = String(player.getUniqueId());
const currentGroups = await getPlayerGroups(uuid);
const result = await attemptPromote(player, trackName, currentGroups);
if (result.ok) {
ok(
ctx.sender,
result.from
? `promoted on <white>${escapeMM(trackName)}<green>: <gray>${escapeMM(result.from)} → <white>${escapeMM(result.to)}`
: `joined <white>${escapeMM(trackName)}<green> at <white>${escapeMM(result.to)}`,
);
return;
}
switch (result.reason) {
case "track-empty":
err(ctx.sender, `track <white>${escapeMM(trackName)}<red> has no rungs`);
return;
case "already-at-top":
warn(ctx.sender, `you're already at the top of <white>${escapeMM(trackName)}`);
return;
case "blocked": {
err(ctx.sender, `can't rank up on <white>${escapeMM(trackName)}<red>:`);
if (result.failure) {
raw(ctx.sender, describeFailure(result.failure));
} else if (result.detail) {
raw(ctx.sender, ` <gray>${escapeMM(result.detail)}`);
}
return;
}
}
}
}
/**
* Picks a track for the player, OR errors with a useful message:
* * explicit name -> validate it exists
* * exactly 1 track they're on -> use it
* * 0 tracks -> tell them they aren't on any
* * 2+ tracks -> tell them to pick one (with /ranks for the GUI)
*/
async function resolveTrack(
player: Player,
explicit: string | undefined,
): Promise<string | null> {
if (explicit) {
const t = await findTrack(explicit);
if (!t) {
err(player, `unknown track: <white>${escapeMM(explicit)}`);
return null;
}
return t.name;
}
const on = await tracksPlayerIsOn(player);
if (on.length === 1) return on[0].name;
if (on.length === 0) {
warn(player, "you aren't on any track. Use <white>/ranks<yellow> to browse.");
return null;
}
const names = on.map((t) => escapeMM(t.name)).join(", ");
warn(
player,
`you're on multiple tracks: <white>${names}<yellow>. Pick one: <white>/rankup <track>`,
);
return null;
}
/**
* `/nextrank [track]` -- shows what's needed for the next rank-up
* WITHOUT spending anything. Convenience around the rank menu's tooltip.
*/
@Command("nextrank", {
description: "Preview what you need for your next rank up",
aliases: ["next"],
})
export class NextRank {
@Arg("track", { type: "string", suggest: trackSuggester, optional: true })
track?: string;
@Run
async run(ctx: CommandCtx) {
if (!isPlayer(ctx.sender)) {
err(ctx.sender, "Only players can use /nextrank.");
return;
}
const player = ctx.sender;
const trackName = await resolveTrack(player, this.track);
if (trackName == null) return;
const track = await findTrack(trackName);
if (!track) return;
const uuid = String(player.getUniqueId());
const currentGroups = await getPlayerGroups(uuid);
const currentIdx = currentRungIndex(track.rungs, currentGroups);
const targetIdx = currentIdx + 1;
if (targetIdx >= track.rungs.length) {
warn(player, `you're at the top of <white>${escapeMM(trackName)}`);
return;
}
const next = track.rungs[targetIdx];
const preview = previewJump(player, track, currentIdx, targetIdx);
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
raw(
ctx.sender,
`<gradient:#06d6a0:#06b97e>${escapeMM(trackName)}</gradient> <dark_gray>· <gray>next: <white>${escapeMM(next.group)}`,
);
if (preview.costs.length === 0) {
raw(ctx.sender, "<gray>Cost: <dark_gray>(none)");
} else {
raw(ctx.sender, "<gold>Cost:");
for (const c of preview.costs) {
const status = c.check.ok ? "<green>✓</green>" : "<red>✗</red>";
raw(ctx.sender, ` ${status} <white>${escapeMM(c.describe)}`);
}
}
if (next.requirements.length > 0) {
raw(ctx.sender, "<yellow>Requirements:");
for (const r of preview.requirements.filter((r) => r.rungIndex === targetIdx)) {
const status = r.pass ? "<green>✓</green>" : "<red>✗</red>";
raw(ctx.sender, ` ${status} <white>${escapeMM(expressionFriendly(r.source))}`);
}
}
raw(
ctx.sender,
preview.reachable
? "<green>You can rank up now. <gray>(use <white>/rankup<gray>)"
: "<red>Not yet ready.",
);
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
}
}