Keep next-level XP requirements positive and overflow-safe
The Standard-mode requirement sums ten Retro levels in an int, which overflows at extreme skill levels and wraps to a garbage value, negative or far too small. Zero or negative experience.yml formula values could also push a requirement to zero or below. A non-positive requirement never drains banked XP, so the level-up loop in checkXp gains levels nonstop until the level cap, which defaults to Integer.MAX_VALUE.
The ten-level sum now accumulates in a long and saturates at Integer.MAX_VALUE, and every computed requirement is clamped to at least 1. Results are unchanged for curves that never left int range.
Tests pin the default-curve requirement values for both scaling modes, the saturation and clamp behavior, and how banked XP converts into levels on the next gain after requirements shrink, grow, or hit the level cap. (commit: 48a049b)
setXPOffline, setLevelOffline, and removeXPOffline mutated a profile freshly loaded from the database and let it go out of scope without saving, so the calls were silent no-ops. They now schedule an async save like their addOfflineXP and addLevelOffline counterparts. (commit: a3b0c35)
Keep leftover banked XP when a level change event is cancelled
The cancel rollback restored the skill level with modifySkill, which zeroes banked XP, then re-added only the XP the reverted levels had consumed; any XP banked beyond those levels was destroyed. The rollback now captures the leftover before the level restore and returns it together with the consumed XP. Command paths like /mmoedit and /skillreset are unaffected because they zero XP before firing the event, leaving no leftover to capture. (commit: f15e53e)
Swap the skill name match cache wholesale on locale reloads
matchSkill cleared its cache map in place when the locale generation changed, so a lookup racing a locale reload could insert a match computed against the old locale after the clear, and that stale entry would survive until the next reload. The cache now lives in a record stamped with the generation it was built for and is replaced wholesale on a generation change; an in-flight lookup writes into its own generation map, which the swap makes unreachable.
Tests pin the cache contract: hits are served without re-reading the locale until the generation changes, a generation change drops every cached match, and failed lookups are never cached. (commit: c41f2e4)
The bundles and their string cache were separate static fields mutated in place by reloadLocale, so a reader on another thread could pair bundles from one locale with cached strings from the other, observe the half-cleared state and throw, or race the override-file copy when two reloads overlapped. The bundles and cache now live in one immutable snapshot behind a volatile field; a reload builds the new snapshot fully and publishes it with a single write, and loads are serialized under a lock. The locale generation bumps after the snapshot is published so generation-keyed consumers rebuild from the new strings. (commit: 10f6bd3)