Markdown Converter
Agent skill for markdown-converter
This document equips AI coding agents working in this repository with everything needed to understand, extend, and safely modify the `@robojs/xp` plugin. It captures architectural intent, invariants, hidden gotchas, performance targets, and file-level references so agents can act with confidence wit
Sign in to like and favorite skills
This document equips AI coding agents working in this repository with everything needed to understand, extend, and safely modify the
@robojs/xp plugin. It captures architectural intent, invariants, hidden gotchas, performance targets, and file-level references so agents can act with confidence without re‑deriving design from code.
Scope: Applies to the entire
packages/@robojs/xp directory and any code that integrates with it (commands, API routes, tests, seeds). Follow the repo‑root AGENTS.md rules in addition to this file.
npx robo add @robojs/xp.robo.js (required). @robojs/server is optional for REST API endpoints.packages/@robojs/xp.xpRate).XP = 5 * level² + 50 * level + 100.Core modules and their responsibilities:
src/core/xp.ts — XP manipulation primitives: add, remove, set, recalc; level math glue.src/runtime/events.ts — Typed EventEmitter singleton, xpChange/levelUp/levelDown.src/runtime/rewards.ts — Role rewards reconciliation with Discord.js safety checks.src/runtime/service.ts — Leaderboard cache (top 100, TTL, invalidation on events).src/store/index.ts — Flashcore persistence, members set, config caching, schema.src/math/curve.ts — Level curve and level/XP conversions.src/math/multiplier.ts — Server/role/user multiplier resolution.src/config.ts — Config API, defaults, validation, global vs guild, cache invalidation.src/events/messageCreate/award.ts — Automatic message award handler with cooldowns and no‑XP checks.Data flow (message XP award):
sequenceDiagram participant User participant Discord participant Award as award.ts Handler participant Store as Flashcore Store participant Events as Event System participant Rewards as Role Reconciliation participant Cache as Leaderboard Cache Note over User,Cache: Message XP Award Flow User->>Discord: Send message Discord->>Award: messageCreate event Award->>Award: Validate bot/DM/channel type Award->>Store: getUser(guildId, userId) Store-->>Award: UserXP or null Award->>Award: Increment messages counter Award->>Store: getConfig(guildId) Store-->>Award: GuildConfig (cached) Award->>Award: Check No-XP channel Award->>Award: Check No-XP roles Award->>Award: Check cooldown alt XP Should Be Awarded Award->>Award: Roll 15–25, apply xpRate × multipliers Award->>Award: Compute new level via curve Award->>Award: Increment xpMessages Award->>Store: putUser(updatedUser) Award->>Events: emit levelUp/levelDown Award->>Events: then emit xpChange Events->>Rewards: reconcile roles Events->>Cache: invalidate leaderboard cache else No Award (no‑XP or cooldown) Award->>Store: putUser(messages only) end
Note: The diagram above reflects the
award.ts pathway (persist → levelUp/levelDown → xpChange). The core XP API in src/core/xp.ts follows persist → xpChange → levelUp/levelDown. Design listeners to be order‑agnostic.
src/core/xp.ts)Exported primitives and expectations:
addXP(guildId, userId, amount, options?)
xpChange then levelUp if level increases.{ oldXp, newXp, oldLevel, newLevel, leveledUp }.{ reason?: string, storeId?: string } (defaults to 'default' store).removeXP(guildId, userId, amount, options?)
xpChange then levelDown if level decreases.{ oldXp, newXp, oldLevel, newLevel, leveledDown }.{ reason?: string, storeId?: string } (defaults to 'default' store).setXP(guildId, userId, totalXp, options?)
xpChange, may emit levelUp/levelDown.{ oldXp, newXp, oldLevel, newLevel }.{ reason?: string, storeId?: string } (defaults to 'default' store).recalcLevel(guildId, userId, options?)
{ oldLevel, newLevel, totalXp, reconciled } and emits events on change.{ storeId?: string } (defaults to 'default' store).getXP(guildId, userId, options?) → number; getLevel(guildId, userId, options?) → number; getUserData(guildId, userId, options?) → UserXP | null.
{ storeId?: string } (defaults to 'default' store).Examples:
// Default store (used by commands) await addXP('guildId', 'userId', 100) // Custom reputation store await addXP('guildId', 'userId', 50, { reason: 'helped_user', storeId: 'reputation' }) // Get data from custom store const credits = await getXP('guildId', 'userId', { storeId: 'credits' })
User data shape (
src/types.ts):
interface UserXP { xp: number level: number lastAwardedAt: number messages: number xpMessages: number }
Important: Two counters —
messages increments for all valid guild messages; xpMessages increments only when XP is awarded.
src/runtime/events.ts)setMaxListeners(0).src/core/xp.ts): persist → xpChange → levelUp/levelDown.src/events/messageCreate/award.ts): persist → levelUp/levelDown → xpChange.Warning: Do not assume a universal ordering between level and XP events across all sources; design listeners to be order‑agnostic.
Event payloads:
interface LevelUpEvent { guildId: string userId: string storeId: string oldLevel: number newLevel: number totalXp: number } interface LevelDownEvent { guildId: string userId: string storeId: string oldLevel: number newLevel: number totalXp: number } interface XPChangeEvent { guildId: string userId: string storeId: string oldXp: number newXp: number delta: number reason?: string }
Built‑in listeners register at module load:
levelUp → role rewards reconciliation (default store only).levelDown → optional reward removal, then reconciliation (default store only).event.storeId).src/config.ts, src/store/index.ts)Hierarchy (highest precedence first):
Note: Each store has independent configuration. Changing config for one store doesn't affect others.
Guild config shape:
interface GuildConfig { cooldownSeconds: number xpRate: number noXpRoleIds: string[] noXpChannelIds: string[] roleRewards: { level: number; roleId: string }[] rewardsMode: 'stack' | 'replace' removeRewardOnXpLoss: boolean leaderboard: { public: boolean } multipliers?: { server?: number; role?: Record<string, number>; user?: Record<string, number> } theme?: { embedColor?: number; backgroundUrl?: string } }
API:
getConfig(guildId) → merged config with defaults, never null.setConfig(guildId, partial) → validates, merges, writes, returns merged config.setGlobalConfig(config) / getGlobalConfig() → manage global defaults; setting global clears all guild config caches.getDefaultConfig() → system defaults.validateConfig(config) → { valid, errors } with strict rules (snowflakes, enums, positive numbers, dedupes, etc.).Caching: In‑memory Map per guild. Invalidated on guild
setConfig and globally cleared on setGlobalConfig.
src/runtime/service.ts)Cache:
Interfaces and functions:
interface LeaderboardEntry { userId: string; xp: number; level: number; rank: number } getLeaderboard(guildId, offset=0, limit=10) → { entries, total } refreshLeaderboard(guildId) → void getUserRank(guildId, userId) → { rank, total } | null invalidateCache(guildId) → void clearAllCaches() → void
Performance (targets): cached < 10ms; refresh 10k users ≤ 200ms; deep pagination O(n log n).
Gotcha: Requests with offset ≥ 100 bypass cache and scan/sort the full dataset.
src/runtime/rewards.ts)Reward definition:
interface RoleReward { level: number roleId: string }
Modes:
Core function:
reconcileRoleRewards(guildId, userId, newLevel, guildConfig) — idempotent, permission/hierarchy safe, skips managed roles, dedupes duplicate roleIds by keeping the highest level.
Level down: if
removeRewardOnXpLoss is true, removes roles above new level (stack) or re‑applies replace mode.
Safety checks: Manage Roles permission; bot highest role above target role; skip managed roles; handle missing roles gracefully with warnings.
src/math/multiplier.ts)Types:
Resolution:
effective = server × max(role) × user (multiplicative). Rounds to 3 decimals to avoid FP artifacts.
Helpers:
getServerMultiplier, getMaxRoleMultiplier(roleIds), getUserMultiplier(userId), resolveMultiplier(config, roleIds, userId).
src/events/messageCreate/award.ts)channelId ∈ noXpChannelIds.any(userRoleId ∈ noXpRoleIds).messages; only increment xpMessages when award succeeds.lastAwardedAt in user record.Check order: bot/DM/type → load user + increment messages → load config → no‑XP channel → no‑XP roles → cooldown → award.
src/commands/*)rank.ts — Show user rank, XP, level, and progress barleaderboard.ts — Paginated server leaderboardxp/rewards.ts — Public view of all role rewards with pagination (refactored to use rewards-ui.ts)xp/* — Require ManageGuild Permission)XP Manipulation:
xp/give.ts, xp/remove.ts, xp/set.ts, xp/recalc.ts — Direct XP operationsUnified Configuration:
xp/config.ts — Single interactive command for configuration management
src/core/config-ui.ts and src/core/rewards-ui.tssrc/events/interactionCreate/xp-config.tsMultipliers (Dedicated Slash Commands):
xp/multiplier/server.ts, xp/multiplier/role.ts, xp/multiplier/user.tsxp/multiplier/remove-role.ts, xp/multiplier/remove-user.ts/xp config UI; managed via traditional slash command subcommandsThe plugin uses Discord.js Components V2 for a unified configuration experience:
UI Builder Modules:
src/core/config-ui.ts — Builds embeds and components for General Settings, No-XP Zones, and Role Rewardssrc/core/rewards-ui.ts — Builds embeds and pagination components for rewards listInteraction Handlers:
src/events/interactionCreate/xp-config.ts — Handles all config interactions (buttons, select menus, modals)src/events/interactionCreate/rewards-pagination.ts — Handles pagination for public rewards listDesign Patterns:
interaction.update()CustomId Schema:
xp_config_{category}_{action}_{userId}[_{payload}]xp_config_edit_cooldown_123456, xp_config_edit_xprate_123456, xp_config_toggle_leaderboard_123456xp_config_noxp_roles_select_123456, xp_config_noxp_channels_select_123456xp_config_rewards_role_select_123456, xp_config_rewards_remove_123456, xp_config_rewards_mode_stack_123456xp_config_category_123456, xp_config_back_main_123456, xp_config_refresh_general_123456general, noxp, rewards (multipliers not included in interactive UI)The
@robojs/xp plugin migrated from traditional slash command subcommands to Discord.js Components V2 for configuration management in version X.X.X.
Before (v1.x):
/xp config/* subcommands (get, set-cooldown, set-xp-rate, add-no-xp-role, etc.)/xp rewards/* subcommands (list, add, remove, mode, remove-on-loss)After (v2.x):
/xp config command with interactive UI/xp rewards command for public listing (no admin permissions)UI Builders (
src/core/config-ui.ts, src/core/rewards-ui.ts):
Interaction Handler (
src/events/interactionCreate/xp-config.ts):
getConfig, setConfig) as old commandsLogging:
xpLogger from src/core/logger.tshasAdminPermission()) appliedgetConfig, setConfig, validateConfig)src/api/xp/* — requires @robojs/server)Base path:
/api/xp/.
GET /api/xp/health.GET /api/xp/users/[guildId], GET /api/xp/users/[guildId]/[userId], POST /api/xp/users/[guildId]/[userId]/recalc.GET /api/xp/leaderboard/[guildId]?offset&limit, GET /api/xp/leaderboard/[guildId]/[userId].GET/PUT /api/xp/config/[guildId]; GET/PUT /api/xp/config/global.GET/PUT /api/xp/config/[guildId]/multipliers.GET/PUT /api/xp/config/[guildId]/rewards.GET /api/xp/stats/[guildId].Conventions: JSON success payloads with metadata; consistent error
{ error, code? } and proper HTTP statuses.
src/store/index.ts)Namespace structure with multi-store support:
['xp', storeId, guildId, ...]['xp', 'default', guildId, 'users']['xp', 'reputation', guildId, 'users']Internal Flashcore keys (colon-separated):
xp:default:{guildId}:users:{userId} → UserXP (default store)xp:reputation:{guildId}:users:{userId} → UserXP (custom store)xp:default:{guildId}:members → string[] of tracked userIdsxp:default:{guildId}:config → GuildConfigxp:global:config → Global defaults (shared across all stores)xp:default:{guildId}:schema → numeric schema version (current: 1)Each store has independent: user data, config, members list, schema version.
Functions (all accept optional
options?: { storeId?: string } parameter):
getUser, putUser, deleteUser, getAllUsers (parallelized), members set helpers.getOrInitConfig, getConfig, putConfig, updateConfig.invalidateConfigCache(guildId, options?) — invalidate specific store; invalidateConfigCache(guildId, { all: true }) — clear all stores for guild; clearConfigCache() — clear entire cache.getGlobalConfig, setGlobalConfig, clearConfigCache.getSchemaVersion, setSchemaVersion.Config cache structure:
Map<string, GuildConfig> keyed by composite 'guildId:storeId' (flat map for simplicity and speed).
Merging and normalization helpers ensure complete configs with defaults and proper deep merges for multipliers.
Schema migrations run automatically when data is accessed. See section 14 for details.
The XP plugin includes a schema migration system for handling data structure changes across versions.
(guildId, storeId) pair has an independent schema version stored in FlashcoreSCHEMA_VERSION = 1 (defined in src/store/index.ts)src/store/migrations.tsgetUser() or getConfig() is called, the system checks if migration is neededSCHEMA_VERSION constantgetUser(guildId, userId, options?) - Checks and migrates user data before returninggetConfig(guildId, options?) - Checks and migrates config before caching/returninggetOrInitConfig(guildId, options?) - Alternative config entry point with same migration logicgetAllUsers(guildId, options?) - No migration check needed (calls getUser() internally)When incrementing
SCHEMA_VERSION, add a migration function to src/store/migrations.ts:
// Example: v2 → v3 migration async function migrateV2ToV3(guildId: string, options?: FlashcoreOptions): Promise<void> { const storeId = resolveStoreId(options) logger.info(`Migrating guild ${guildId} store ${storeId} from v2 to v3`) // 1. Load all users const members = await getMembers(guildId, options) // 2. Modify data structure for (const userId of members) { const user = await getUser(guildId, userId, options) if (user) { // Add new field or transform existing data const updated = { ...user, newField: defaultValue } await putUser(guildId, userId, updated, options) } } // 3. Schema version is updated automatically by migrateGuildData() } // Register migration migrations.set(3, migrateV2ToV3)
getAllUsers() implementation)__tests__/store.test.ts for each migrationgetUser()/getConfig() triggers, preventing duplicate concurrent runs.getUser()/getConfig() call (schema version lookup)(guildId, storeId) pair on first data access after upgradeSCHEMA_VERSION, migration skipped (O(1) check)getAllUsers() with p-limit)This section documents the customizable level progression curves supported by
@robojs/xp.
XP = 5*level² + 50*level + 100.quadratic, linear, exponential, lookup (all serializable, persisted via Flashcore).PluginOptions.levels.getCurve(guildId, storeId) may return a runtime LevelCurve (sync/async).config/plugins/robojs/xp.ts → PluginOptions.levels.getCurve.(guildId: string, storeId: string) => LevelCurve | null | Promise<LevelCurve | null>.getResolvedCurve(guildId, storeId) in src/math/curves.ts.null to fall through. Supports sync/async.['xp', storeId, guildId, 'config'] within GuildConfig.levels.LevelCurveConfig (discriminated union of presets).getConfig(guildId, { storeId }), converted by buildCurveFromPreset() in src/math/curves.ts.config.set() (and future /xp config).src/math/curve.ts used when no other curve applies.XP = 5*level² + 50*level + 100 (default).flowchart TD A[getResolvedCurve(guildId, storeId)] --> B{Cache hit?} B -- yes --> R[Return cached LevelCurve] B -- no --> C[resolveLevelCurve] C --> D{Plugin getCurve?} D -- returns LevelCurve --> K[Cache + Return] D -- null/absent --> E{Guild preset?} E -- yes --> F[buildCurveFromPreset] F --> K E -- no --> G[Default quadratic] G --> K
Map<string, LevelCurve> keyed by 'guildId:storeId' (see src/math/curves.ts).getResolvedCurve resolves and caches; subsequent calls are O(1).invalidateCurveCache(guildId, storeId?) and clearAllCurveCache() provided; integration to call after config.set() is planned in src/store/index.ts.src/core/xp.ts: addXP, removeXP, setXP, recalcLevel fetch a resolved curve before level math and enforce maxLevel caps.src/math/curve.ts: computeLevelFromTotalXp, xpNeededForLevel, totalXpForLevel accept an optional LevelCurve and delegate when provided; fall back to default quadratic otherwise.src/config.ts: validates LevelCurveConfig shapes when present (positive params, sorted thresholds, etc.).src/math/curves.ts: builders buildQuadraticCurve, buildLinearCurve, buildExponentialCurve, buildLookupCurve + resolution and cache helpers.LevelCurveConfig (serializable presets): discriminated union with type in src/types.ts.LevelCurve (runtime): { xpForLevel(level): number; levelFromXp(totalXp): number; maxLevel?: number }.PluginOptions.levels.getCurve: (guildId, storeId) => LevelCurve | null | Promise<...> (highest precedence).levelFromXp must be the mathematical inverse of xpForLevel. Presets implement correct inverses; custom callbacks must, too.maxLevel enforcement: applied in addXP, setXP, and recalcLevel; users cannot exceed caps.config.set(); restart or call invalidation helpers as a workaround.src/types.ts — preset/runtime curve type definitions.src/math/curves.ts — curve builders, resolution, caching utilities.src/math/curve.ts — math functions with optional curve parameter.src/core/xp.ts — XP operations integrating curve resolution and caps.src/config.ts — validation for curve presets.config/plugins/robojs/xp.ts — user config site for getCurve.PERFORMANCE.md)Targets:
Complexity cheatsheet:
Recommended limits: users/guild < 50k (max 100k), rewards < 20 (max 100), multipliers < 100 (max 1k), concurrent awards < 1k/s (max 5k/s).
Cache tuning knobs:
MAX_CACHE_SIZE (default 100), CACHE_TTL (default 60s).
Common recipes:
levelUp, send embed; see seed/events/_start/level-announcements.ts.XP.addXP(..., { reason: 'contest_winner' }), check leveledUp.XP.removeXP(..., { reason: 'moderation' }), check leveledDown.config.multipliers.user[userId].xpChange, forward to analytics service.levelUp, grant currency/items.leaderboard.get and leaderboard.getRank for embeds.Listener best practices: register in
src/events/_start/; use async/await; never throw from listeners; prefer queues for heavy work; use reason for audit.
Two counters:
messages vs xpMessages — discrepancy expected (no‑XP/cooldown).
Cooldown per user (global across channels), tracked with
lastAwardedAt.
Multipliers multiply; they never add.
server × max(role) × user.
Role multiplier is MAX across user roles, not sum.
Rewards deduped by
roleId — highest level wins.
Bot role hierarchy: bot top role must be higher than reward roles; Manage Roles permission required.
Managed roles skipped (integration‑managed).
Cache invalidated on every XP event — tune TTL if thrashing.
Deep pagination (offset ≥ 100) triggers full scan.
Global config updates clear all guild config caches.
Level calculation deterministic;
recalcLevel is idempotent fixer.
Events emitted after persistence; listeners must tolerate failures gracefully.
Role ops are async and can rate limit; failures logged, not thrown.
No‑XP roles: ANY matching role blocks XP.
Formula application:
finalXP = base × xpRate × multiplier.
Level down does not remove roles unless configured; replace mode always reconciles to one role.
Flashcore keys isolated under
xp namespace.
Schema version stored for future migrations (current: 1).
getAllUsers fetches in parallel; be mindful of very large guilds.
Stable sort with secondary key userId to avoid rank flicker on ties.
Built-in commands (
/rank, /leaderboard, /xp) only interact with the default store. Custom stores require building custom commands.
Each store has independent config — changing config for one store doesn't affect others.
Leaderboard cache is per-store — each store maintains its own top 100 cache.
Events include
storeId field — listeners can filter by store. Role rewards only process default store events.
Role rewards only trigger for the default store to avoid conflicts. Custom stores (reputation, credits, etc.) never grant Discord roles, preventing scenarios where multiple progression systems would compete for the same roles.
Component Interaction Timeout: Discord component interactions (buttons, select menus) expire after 15 minutes. The
/xp config UI remains functional, but if a user waits >15 minutes before clicking a button, the interaction will fail with "Unknown Interaction". The command must be re-invoked to get fresh components.
Modal Character Limits: Modal text inputs have strict limits:
TextInputStyle.Short max 4000 characters, TextInputStyle.Paragraph max 4000 characters. The config modals use Short style with validation (cooldown: 1-4 chars, XP rate: 1-5 chars, role/channel IDs: 17-19 chars) to stay well within limits.
Select Menu Limits: Discord select menus support max 25 options. When removing no-XP roles/channels or rewards, if >25 items exist, the select menu will only show the first 25. Users must remove items in batches. The UI builders in
config-ui.ts handle this by slicing to first 25 options.
Modal Submission Validation: Modal submissions in
xp-config.ts must validate all inputs before calling setConfig(). Invalid inputs (non-numeric, out-of-range, invalid snowflakes) should reply with ephemeral error embeds, not throw exceptions. Use parseInt(), parseFloat(), and regex /\d{17,19}/ for validation.
Ephemeral vs Public Responses: Config interactions use ephemeral responses for errors and confirmations to keep the UI clean. The main config message remains non-ephemeral so it persists in the channel. Pagination for
/xp rewards uses non-ephemeral responses for public visibility.
Component CustomId Length: Discord customIds have a 100-character limit. The schema
xp_config_{category}_{action}_{userId} stays well under this limit (typical: 40-50 chars). Avoid encoding large payloads in customIds; use modals or select menus for data collection instead.
Interaction Handler Routing: The
xp-config.ts handler uses prefix matching (xp_config_) to route interactions. Other plugins' interactions with similar prefixes could conflict. The namespace xp_config_ is specific enough to avoid collisions, but future handlers should follow the same namespacing pattern.
Permission Checks on Every Interaction: Unlike slash commands where permissions are checked once at invocation, component interactions require explicit permission checks in the handler. The
xp-config.ts handler calls hasAdminPermission() on every button/select/modal interaction to prevent privilege escalation if a user's permissions change mid-session.
User Validation for Session Isolation: CustomIds include
userId to prevent users from clicking others' config buttons. The handler validates interaction.user.id === userId extracted from customId. Without this check, any user could hijack another's config session.
Modal Submission Context Loss: When showing a modal with
interaction.showModal(), the original message context is lost. The handler must fetch the original config message to update it after modal submission. Use interaction.message for button interactions, but for modals, you may need to track the message ID or re-fetch the channel's recent messages.
The XP plugin supports multiple isolated data stores for parallel progression systems.
Purpose and capabilities:
'default') is used by all built-in commands (/rank, /leaderboard, /xp).Flashcore namespace structure:
['xp', 'default', guildId, 'users']['xp', 'reputation', guildId, 'users']['xp', storeId, guildId] → stores config, members, schemaUse case examples:
'default', 'gold', 'gems')'combat', 'crafting', 'trading')'season1', 'season2', 'season3')Cache structure:
Map<guildId, Map<storeId, GuildConfig>> (nested maps for isolation).
Important constraints:
'default' store. This is enforced by early returns in src/runtime/rewards.ts event listeners.Implementation Details:
storeId field (implemented in phase 3).if (storeId !== 'default') return.event.storeId for per-store invalidation.Map<guildId, Map<storeId, data>>.Map<string, GuildConfig> keyed by 'guildId:storeId'.Map<guildId, Map<storeId, LeaderboardEntry[]>>.Event Filtering Pattern:
Listeners can filter events by store:
events.on('levelUp', (event) => { if (event.storeId === 'reputation') { // Handle reputation level-up } else if (event.storeId === 'default') { // Handle default store level-up (role rewards handled automatically) } })
Role rewards listeners use early return:
events.on('levelUp', async (event) => { if (event.storeId !== 'default') { logger.debug('Skipping role rewards for non-default store') return } // ... role reconciliation logic })
CRITICAL STANDARD: All new parameters must be added via options objects, never as standalone parameters.
Rationale: Future-proofing for additional options (e.g., remote sources, caching hints, transaction IDs, audit metadata).
Pattern: Functions without existing options should have options objects created:
// ✅ CORRECT: New parameter in options object getUser(guildId, userId, options?: { storeId?: string }) addXP(guildId, userId, amount, options?: { reason?: string, storeId?: string }) // ❌ WRONG: New parameter as standalone getUser(guildId, userId, storeId?: string) addXP(guildId, userId, amount, options?, storeId?: string)
Requirements:
storeId defaults to 'default').This standard applies to all future API additions across the plugin.
Core exports and types:
src/index.ts — top‑level exports (XP, config, leaderboard, events, math, rewards, constants).src/types.ts — type definitions.src/config.ts — config API and validation.Core logic:
src/core/xp.ts, src/core/utils.ts.Runtime:
src/runtime/events.ts, src/runtime/rewards.ts, src/runtime/service.ts.Storage:
src/store/index.ts.src/store/migrations.ts — Schema migration system with version-specific migration functions.Math:
src/math/curve.ts, src/math/multiplier.ts.Events:
src/events/messageCreate/award.ts.Commands:
src/commands/xp/config.ts — Unified configuration command (Components V2)src/commands/xp/rewards.ts — Public rewards list (refactored)src/commands/xp/multiplier/* — Multiplier management commandssrc/commands/xp/give.ts, src/commands/xp/remove.ts, src/commands/xp/set.ts, src/commands/xp/recalc.ts — XP manipulationsrc/commands/rank.ts, src/commands/leaderboard.ts — User-facing commandsUI Builders:
src/core/config-ui.ts — Config interface builders (embeds, buttons, modals, select menus)src/core/rewards-ui.ts — Rewards list builders (embeds, pagination buttons)Interaction Handlers:
src/events/interactionCreate/xp-config.ts — Config component interactionssrc/events/interactionCreate/rewards-pagination.ts — Rewards paginationAPI:
src/api/xp/health.ts, src/api/xp/users/[guildId]/[userId].ts, src/api/xp/users/[guildId]/[userId]/recalc.ts,
src/api/xp/leaderboard/[guildId].ts, src/api/xp/leaderboard/[guildId]/[userId].ts,
src/api/xp/config/[guildId].ts, src/api/xp/config/global.ts,
src/api/xp/config/[guildId]/multipliers.ts, src/api/xp/config/[guildId]/rewards.ts,
src/api/xp/stats/[guildId].ts, src/api/xp/utils.ts.Seeds & tests:
seed/events/_start/level-announcements.ts.__tests__/*.test.ts and helpers.Docs:
README.md, PERFORMANCE.md, DEVELOPMENT.md, and this AGENTS.md.Use exactly one forked logger named after the plugin.
src/core/logger.ts:import { logger } from 'robo.js/logger.js' export const xpLogger = logger.fork('xp')
xpLogger — do not create additional forks like logger.fork('xp:service') or per‑file forks.Current state: root logger usage exists in places; migrate gradually to the shared
xpLogger to meet standards.
Benefits: consistent namespacing, easy filtering, single place to adjust level.
Components V2 Handlers:
src/events/interactionCreate/xp-config.ts — Uses xpLogger for all logging (interaction routing, validation errors, config updates)src/events/interactionCreate/rewards-pagination.ts — Uses xpLogger for pagination events and errorssrc/core/config-ui.ts — Pure UI builder, no logging (stateless functions)src/core/rewards-ui.ts — Pure UI builder, no logging (stateless functions)Migration Status:
xpLogger from day oneWhen modifying
@robojs/xp, you MUST update this file to reflect changes.
Update triggers:
getCurve callback signature, validation rules.How to update:
Verification checklist:
This is a living document. Keep it current so humans and AI agents can maintain high velocity without regressions.