lib/track-engine.ts
18 KB · sha256:1d63ef5c70d38611013244efcf7becfab2110928bd3510f7fb6c9c6de4cbf6c8
import {
findTrack,
listTracks,
promote as ladderPromote,
demote as ladderDemote,
type TrackDoc,
type TrackRung,
type TransactionConfig,
} from "../models/Track";
import {
compileExpr,
evalExpr,
makePapiResolver,
ExprParseError,
type CompiledExpr,
} from "./expr";
import {
getHandler,
missingPluginFor,
type TransactionCheck,
} from "./transactions";
import { refreshOne } from "./attachments";
import { expressionFriendly } from "./placeholders";
import {
addGroup,
getPlayerGroups,
removeGroup,
} from "../models/Player";
const DEFAULT_POLL_SECONDS = 30;
const POLL_BATCH = 8;
export interface RungFailure {
requirements: { source: string; reason: string }[];
costs: {
handler: string;
describe: string;
reason: string;
need?: number | string;
have?: number | string;
}[];
}
export type RungEvaluation =
| { ok: true }
| { ok: false; failure: RungFailure };
export type PromoteResult =
| { ok: true; from: string | null; to: string }
| { ok: false; reason: "track-empty" | "already-at-top" | "blocked"; failure?: RungFailure; detail?: string };
const compiledCache = new Map<string, CompiledExpr | ExprParseError>();
function compile(source: string): CompiledExpr | ExprParseError {
let hit = compiledCache.get(source);
if (hit !== undefined) return hit;
try {
hit = compileExpr(source);
} catch (e) {
hit = e instanceof ExprParseError ? e : new ExprParseError(String((e as Error)?.message ?? e), source, 0);
}
compiledCache.set(source, hit);
return hit;
}
export function evaluateRung(player: Player, rung: TrackRung): RungEvaluation {
const failure: RungFailure = { requirements: [], costs: [] };
const resolver = makePapiResolver(player);
for (const source of rung.requirements ?? []) {
const compiled = compile(source);
if (compiled instanceof ExprParseError) {
failure.requirements.push({ source, reason: compiled.message });
continue;
}
let pass = false;
try {
pass = evalExpr(compiled, resolver);
} catch (e) {
failure.requirements.push({ source, reason: (e as Error)?.message ?? String(e) });
continue;
}
if (!pass) failure.requirements.push({ source, reason: "condition not met" });
}
for (const cfg of rung.costs ?? []) {
const handler = getHandler(cfg.handler);
if (!handler) {
const missing = missingPluginFor(cfg.handler);
failure.costs.push({
handler: cfg.handler,
describe: cfg.handler,
reason: missing ? `requires ${missing}` : "unknown handler",
});
continue;
}
let check: TransactionCheck;
try {
const out = handler.check(player, cfg);
check = out instanceof Promise ? { ok: false, reason: "async check not allowed" } : out;
} catch (e) {
failure.costs.push({
handler: cfg.handler,
describe: handler.describe(cfg),
reason: (e as Error)?.message ?? String(e),
});
continue;
}
if (!check.ok) {
failure.costs.push({
handler: cfg.handler,
describe: handler.describe(cfg),
reason: check.reason,
need: check.need,
have: check.have,
});
}
}
if (failure.requirements.length === 0 && failure.costs.length === 0) {
return { ok: true };
}
return { ok: false, failure };
}
function findRungIndex(rungs: TrackRung[], currentGroups: string[]): number {
return rungs.findIndex((r) => currentGroups.includes(r.group));
}
export async function attemptPromote(
player: Player,
trackName: string,
currentGroups: string[],
): Promise<PromoteResult> {
const track = await findTrack(trackName);
if (!track || track.rungs.length === 0) {
return { ok: false, reason: "track-empty" };
}
const currentIdx = findRungIndex(track.rungs, currentGroups);
const targetIdx = currentIdx === -1 ? 0 : currentIdx + 1;
if (targetIdx >= track.rungs.length) {
return { ok: false, reason: "already-at-top" };
}
const target = track.rungs[targetIdx];
const evaluation = evaluateRung(player, target);
if (!evaluation.ok) {
return { ok: false, reason: "blocked", failure: evaluation.failure };
}
const consumed: { handler: string; cfg: TransactionConfig }[] = [];
try {
for (const cfg of target.costs ?? []) {
const handler = getHandler(cfg.handler);
if (!handler) throw new Error(`handler "${cfg.handler}" vanished mid-promote`);
await handler.consume(player, cfg);
consumed.push({ handler: cfg.handler, cfg });
}
} catch (e) {
for (const c of consumed.reverse()) {
const handler = getHandler(c.handler);
try { handler && (await handler.refund(player, c.cfg)); }
catch (refundErr) {
console.error(
`ward.engine: refund of ${c.handler} failed during walkback:`,
(refundErr as Error)?.stack ?? refundErr,
);
}
}
return { ok: false, reason: "blocked", detail: (e as Error)?.message ?? String(e) };
}
const ladder = await ladderPromote(String(player.getUniqueId()), trackName);
if (!ladder.ok) {
for (const c of consumed.reverse()) {
const handler = getHandler(c.handler);
try { handler && (await handler.refund(player, c.cfg)); } catch {}
}
return { ok: false, reason: "blocked", detail: ladder.reason };
}
await refreshOne(String(player.getUniqueId()));
return { ok: true, from: ladder.from, to: ladder.to };
}
export async function attemptDemote(
player: Player,
trackName: string,
): Promise<{ ok: true; from: string | null; to: string } | { ok: false; reason: string }> {
const ladder = await ladderDemote(String(player.getUniqueId()), trackName);
if (ladder.ok) await refreshOne(String(player.getUniqueId()));
return ladder;
}
/** Index of the rung the player is currently on, or -1 if not on the track. */
export function currentRungIndex(rungs: TrackRung[], currentGroups: string[]): number {
return findRungIndex(rungs, currentGroups);
}
export interface CumulativeCost {
handler: string;
/** Sum of `amount` across all rungs in the jump that route through this handler. */
total: number;
/** Handler-rendered description of the cumulative amount (e.g. `$750`). */
describe: string;
/** Result of checking the cumulative total against the player. */
check: TransactionCheck;
}
export interface JumpPreview {
/** Whether the player can complete every rung in the jump RIGHT NOW. */
reachable: boolean;
/** First rung whose requirements fail (relative to `track.rungs` indexing). */
blocker?: { rungIndex: number; group: string; failure: RungFailure };
/** Per-handler cumulative cost across every rung in the jump. */
costs: CumulativeCost[];
/** Every requirement across the jump, with per-row pass/fail. */
requirements: { rungIndex: number; source: string; pass: boolean; reason?: string }[];
}
/**
* Preview a multi-rung jump from the player's current rung (exclusive)
* up to and INCLUDING `targetIdx`. Used by the player-facing rank menu
* to colour locked rungs (red = blocker hit, yellow = reachable) and
* to show "you'll need a total of $X" before the confirmation step.
*
* Heuristic: per-rung requirements are evaluated against the player's
* CURRENT placeholder state -- we don't simulate intermediate group
* changes. Costs are summed per-handler and `check()`-ed against the
* cumulative total so the indicator reflects what they could actually
* pay in one go.
*/
export function previewJump(
player: Player,
track: TrackDoc,
currentIdx: number,
targetIdx: number,
): JumpPreview {
const start = currentIdx + 1;
const end = Math.min(targetIdx, track.rungs.length - 1);
const requirements: JumpPreview["requirements"] = [];
const totals = new Map<string, number>();
let blocker: JumpPreview["blocker"] | undefined;
for (let i = start; i <= end; i++) {
const rung = track.rungs[i];
const evaluation = evaluateRung(player, rung);
if (!evaluation.ok) {
for (const r of evaluation.failure.requirements) {
requirements.push({ rungIndex: i, source: r.source, pass: false, reason: r.reason });
}
// Cost-side failures here are SINGLE-rung checks (insufficient funds
// for this rung's amount alone). They don't necessarily indicate the
// CUMULATIVE jump is unaffordable -- we re-check totals below. So
// we only treat a requirement failure as a hard blocker.
if (!blocker && evaluation.failure.requirements.length > 0) {
blocker = { rungIndex: i, group: rung.group, failure: evaluation.failure };
}
} else {
for (const r of rung.requirements) requirements.push({ rungIndex: i, source: r, pass: true });
}
for (const cfg of rung.costs ?? []) {
const amount = typeof cfg.amount === "number" ? cfg.amount : 0;
totals.set(cfg.handler, (totals.get(cfg.handler) ?? 0) + amount);
}
}
const costs: CumulativeCost[] = [];
for (const [handlerId, total] of totals) {
const handler = getHandler(handlerId);
const cfg: TransactionConfig = { handler: handlerId, amount: total };
let check: TransactionCheck;
let describe: string;
if (!handler) {
const missing = missingPluginFor(handlerId);
check = { ok: false, reason: missing ? `requires ${missing}` : "unknown handler" };
describe = handlerId;
} else {
describe = handler.describe(cfg);
try {
const out = handler.check(player, cfg);
check = out instanceof Promise ? { ok: false, reason: "async check not allowed" } : out;
} catch (e) {
check = { ok: false, reason: (e as Error)?.message ?? String(e) };
}
}
costs.push({ handler: handlerId, total, describe, check });
}
const costsOk = costs.every((c) => c.check.ok);
const reachable = blocker === undefined && costsOk && start <= end;
return { reachable, blocker, costs, requirements };
}
export type JumpResult =
| { ok: true; from: string | null; to: string; rungsClimbed: number }
| { ok: false; reason: "track-empty" | "no-jump" | "blocked"; failure?: RungFailure; detail?: string };
/**
* Atomic multi-rung promotion. Evaluates every rung from current+1 to
* `targetIdx`, consumes each one's costs in order, and only flips the
* player's group membership once every consume has succeeded. If
* anything fails partway, refunds in reverse order -- the player ends
* exactly where they started.
*
* Single-rung jumps are handled here too (it's just a degenerate loop
* of length 1), so callers don't need to branch.
*/
export async function attemptJump(
player: Player,
trackName: string,
targetIdx: number,
): Promise<JumpResult> {
const track = await findTrack(trackName);
if (!track || track.rungs.length === 0) return { ok: false, reason: "track-empty" };
const uuid = String(player.getUniqueId());
const currentGroups = await getPlayerGroups(uuid);
const currentIdx = findRungIndex(track.rungs, currentGroups);
const startIdx = currentIdx + 1;
const endIdx = Math.min(targetIdx, track.rungs.length - 1);
if (endIdx < startIdx) return { ok: false, reason: "no-jump" };
// Phase 1: evaluate every rung up front. Bails on the first failure
// without having consumed anything, so the player keeps their state.
for (let i = startIdx; i <= endIdx; i++) {
const evaluation = evaluateRung(player, track.rungs[i]);
if (!evaluation.ok) {
return { ok: false, reason: "blocked", failure: evaluation.failure };
}
}
// Phase 2: consume costs rung-by-rung. We keep a journal so a failure
// partway through can be reversed precisely. Refunds run in reverse
// order to match the consume order (FIFO on, LIFO off).
const journal: { handler: string; cfg: TransactionConfig }[] = [];
try {
for (let i = startIdx; i <= endIdx; i++) {
for (const cfg of track.rungs[i].costs ?? []) {
const handler = getHandler(cfg.handler);
if (!handler) throw new Error(`handler "${cfg.handler}" vanished mid-jump`);
await handler.consume(player, cfg);
journal.push({ handler: cfg.handler, cfg });
}
}
} catch (e) {
for (const c of journal.reverse()) {
const handler = getHandler(c.handler);
try { handler && (await handler.refund(player, c.cfg)); }
catch (refundErr) {
console.error(
`ward.engine: refund of ${c.handler} failed during jump walkback:`,
(refundErr as Error)?.stack ?? refundErr,
);
}
}
return { ok: false, reason: "blocked", detail: (e as Error)?.message ?? String(e) };
}
// Phase 3: flip group membership. Drop the player's current rung group
// (if any) and add the target. Intermediate groups are intentionally
// skipped -- the player jumps DIRECTLY to the target rather than
// accumulating every intermediate group. Matches "rank up to D" UX:
// you end up at D, not at B+C+D.
const fromGroup = currentIdx === -1 ? null : track.rungs[currentIdx].group;
const toGroup = track.rungs[endIdx].group;
try {
if (fromGroup) await removeGroup(uuid, fromGroup);
await addGroup(uuid, toGroup);
} catch (e) {
for (const c of journal.reverse()) {
const handler = getHandler(c.handler);
try { handler && (await handler.refund(player, c.cfg)); } catch {}
}
return { ok: false, reason: "blocked", detail: (e as Error)?.message ?? String(e) };
}
await refreshOne(uuid);
return { ok: true, from: fromGroup, to: toGroup, rungsClimbed: endIdx - startIdx + 1 };
}
/**
* Tracks the player is "on" -- the rungs include at least one of their
* groups. Used by the player rank menu to pick a default vs offering a
* selector. Tracks the player has never touched are NOT considered "on"
* even though they could start at rung 0 -- those are discoverable via
* the selector's "all tracks" fallback.
*/
export async function tracksPlayerIsOn(player: Player): Promise<TrackDoc[]> {
const uuid = String(player.getUniqueId());
const [tracks, currentGroups] = await Promise.all([
listTracks(),
getPlayerGroups(uuid),
]);
return tracks.filter((t) => findRungIndex(t.rungs, currentGroups) !== -1);
}
export function describeFailure(failure: RungFailure): string {
const lines: string[] = [];
for (const r of failure.requirements) {
lines.push(`<gray>•</gray> <red>requirement:</red> <white>${mmEscape(expressionFriendly(r.source))}</white> <dark_gray>(${mmEscape(r.reason)})`);
}
for (const c of failure.costs) {
const have = c.have != null ? ` <dark_gray>(have ${mmEscape(String(c.have))})` : "";
lines.push(`<gray>•</gray> <red>cost:</red> <white>${mmEscape(c.describe)}</white> <dark_gray>— ${mmEscape(c.reason)}${have}`);
}
return lines.join("<newline>");
}
function mmEscape(s: string): string {
return s.replace(/</g, "\\<");
}
const lastChecked = new Map<string, number>();
let tickerId: number | null = null;
interface BukkitTask { cancel(): void; getTaskId(): number; }
interface SchedulerRef {
runTaskTimer(plugin: bukkit.plugin.Plugin, runnable: unknown, delayTicks: number, periodTicks: number): BukkitTask;
cancelTask(taskId: number): void;
}
export function startAutoPromoteTicker(plugin: bukkit.plugin.Plugin): void {
if (tickerId != null) return;
const scheduler = rune.bukkit.getScheduler() as SchedulerRef;
const runnable = rune.implement("java.lang.Runnable", {
run: () => {
void tick().catch((e) => {
console.warn(
"ward.engine: auto-promote tick threw:",
(e as Error)?.stack ?? e,
);
});
},
});
const task = scheduler.runTaskTimer(plugin, runnable, 200, 100);
tickerId = task.getTaskId();
}
export function stopAutoPromoteTicker(): void {
if (tickerId == null) return;
try { rune.bukkit.getScheduler().cancelTask(tickerId); } catch {}
tickerId = null;
}
async function tick(): Promise<void> {
const players = rune.bukkit.getOnlinePlayers();
if (players.length === 0) return;
const tracks = await listTracks();
const autoTracks = tracks.filter((t) => t.rungs.some((r) => r.autoPromote));
if (autoTracks.length === 0) return;
const now = Date.now();
const batch: Player[] = [];
for (const p of players) {
const uuid = String(p.getUniqueId());
const due = lastChecked.get(uuid) ?? 0;
if (now < due) continue;
batch.push(p);
if (batch.length >= POLL_BATCH) break;
}
if (batch.length === 0) return;
for (const player of batch) {
const uuid = String(player.getUniqueId());
try {
await processPlayer(player, autoTracks);
} catch (e) {
console.warn(`ward.engine: auto-promote for ${player.getName()} threw:`, (e as Error)?.message ?? e);
}
const longest = autoTracks.reduce((acc, t) => Math.max(acc, (t.pollSeconds ?? DEFAULT_POLL_SECONDS)), DEFAULT_POLL_SECONDS);
lastChecked.set(uuid, now + longest * 1000);
}
}
async function processPlayer(player: Player, tracks: TrackDoc[]): Promise<void> {
const uuid = String(player.getUniqueId());
let currentGroups = await getPlayerGroups(uuid);
for (const track of tracks) {
const currentIdx = findRungIndex(track.rungs, currentGroups);
const targetIdx = currentIdx === -1 ? 0 : currentIdx + 1;
if (targetIdx >= track.rungs.length) continue;
const target = track.rungs[targetIdx];
if (!target.autoPromote) continue;
const evaluation = evaluateRung(player, target);
if (!evaluation.ok) continue;
const result = await attemptPromote(player, track.name, currentGroups);
if (result.ok) {
try {
player.sendMessage(
rune.mm(
`<dark_gray>[<gradient:#9b87f5:#5b3df5>Ward</gradient><dark_gray>]</dark_gray> ` +
`<green>Promoted on <white>${mmEscape(track.name)}</white>: ` +
`<gray>${mmEscape(result.from ?? "(none)")} → <white>${mmEscape(result.to)}`,
),
);
} catch {}
currentGroups = await getPlayerGroups(uuid);
}
}
}
export function clearAutoPromoteState(uuid: string): void {
lastChecked.delete(uuid);
}