Achievements
Lasers-Enigma keeps a permanent record of what each player has accomplished, and lets another plugin add records of its own. This page is the contract for that: what a companion plugin may rely on, what it must do itself, and why the boundary sits where it does.
The player-facing description lives on Achievements โ read that first if you want to know what a player sees.
The shape of the contractโ
Everything crosses the boundary in one direction. Your plugin describes its achievements once, then pushes an award the moment it decides the fact is true. Lasers-Enigma never evaluates a condition on your behalf.
That is a deliberate restriction. A shared condition language would tie the two release cycles together: every new kind of condition would need a Lasers-Enigma release, and every internal refactor would risk breaking a companion plugin's predicate. Pushing an award needs neither.
your plugin โโdescribeโโโถ Lasers-Enigma (once, at enable)
your plugin โโawardโโโโโถ Lasers-Enigma (when you decide the fact is true)
your plugin โโโreadโโโโโ Lasers-Enigma (what a player holds)
Getting the APIโ
Everything public lives in eu.lasersenigma.achievement.api. Nothing else in Lasers-Enigma is API.
AchievementApi api = AchievementApi.get();
get() throws IllegalStateException while Lasers-Enigma is not enabled, rather than returning null
that would surface as a confusing failure later. Bukkit disables a plugin before the plugins it depends
on, so your onDisable still finds the API in place.
Every call is main-thread only, and the rule is enforced rather than merely stated: claimNamespace,
registerCategory, register, unregisterAll and both award overloads throw IllegalStateException
naming the offender โ "AchievementApi.register must be called from the main thread." โ when they are
reached from anywhere else. The registry, the database and the event bus are none of them safe off the
main thread; without the check, an async call gives you corruption that surfaces somewhere else entirely,
with it you get a stack trace pointing at the line that did it. The three read calls (isUnlocked,
unlocked, unlockedAt) do not currently carry the check, but they are bound by the same rule.
Registeringโ
Claim a namespaceโ
api.claimNamespace(this, "lepsu");
Every id you register must live inside a namespace you hold. le belongs to Lasers-Enigma and is
refused. Claiming protects well-behaved plugins from colliding with each other โ it is not a security
boundary, and the section on trust below says what follows from that.
Register categories and achievementsโ
api.registerCategory(this, AchievementCategory.builder("lepsu:campaign")
.title((subject, viewer) -> translate(viewer, "campaign.tab"))
.icon(AchievementIcon.material("BOOK"))
.order(50)
.build());
api.register(this, AchievementDefinition.builder("lepsu:chapter_one")
.category("lepsu:campaign")
.rarity(Rarity.COMMON)
.title((subject, viewer) -> translate(viewer, "chapter_one.title"))
.description((subject, viewer) -> translate(viewer, "chapter_one.description"))
.icon(AchievementIcon.material("ENDER_EYE"))
.progress(playerUUID -> new AchievementProgress(solvedIn(playerUUID, "solo.1"), 12))
.build());
Both take your plugin as their first argument, and the namespace claim is checked against it: an id
in a namespace another plugin holds is refused with NOT_YOURS, one in a namespace nobody has claimed
with NAMESPACE_NOT_CLAIMED. The check exists for exactly the mistake it prevents โ a plugin that
mistypes a namespace, or copies an example without changing it, would otherwise silently rewrite the
title, description and icon of somebody else's achievements and be told it had worked.
Both return a RegisterResult rather than throwing. Registration happens while a server boots, and one
malformed definition must never take a plugin down with it โ so read the result and log what you get.
โ ๏ธ That promise covers
register(), notbuild(). The builders still validate what they are handed, and they do it by throwing:AchievementDefinition.builder(id).build()raisesIllegalStateExceptionwhen no title or no description was supplied, andAchievementCategory.builder(key).build()raises it when no title was. Those are programming mistakes, caught where they are made rather than carried into the registry โ but if you generate definitions from content that may be incomplete, build each one inside your own guard.
Everything a builder accepts:
On AchievementDefinition.builder(id) | Default | Meaning |
|---|---|---|
.title(TextProvider) | required | The name players read. Leave it out and build() throws. Called with the subject and the viewer, so it can be translated per viewer and can name the subject. |
.description(TextProvider) | required | The line under the title, same contract โ required too, including on a hidden achievement, which needs one for the moment it is unlocked. |
.category(String key) | none | The tab it appears under. A definition with no category lands in <your namespace>:other rather than nowhere. |
.icon(AchievementIcon) | a paper sheet | AchievementIcon.material("ENDER_EYE") for a block or item, AchievementIcon.headTexture("<hash>") for a custom player head. A material this server does not know falls back to paper instead of failing. |
.rarity(Rarity) | COMMON | Presentation only: the colour the title is written in. |
.order(int) | 0 | Sort position inside the category; ties break on the id, so an unordered set is still stable. |
.hidden(boolean) | false | A hidden achievement shows as ??? until it is unlocked, or until its prerequisite is. It still counts in the totals, and the server-wide announcement calls it a secret achievement instead of naming it. |
.prerequisite(String id) | none | Ordering and reveal only, never a gate: the child can be unlocked without the parent, and unlocking the child never unlocks the parent. It must name an achievement in the same namespace; a prerequisite belonging to another plugin is not supported and gets the whole registration refused with FOREIGN_PREREQUISITE. |
.progress(ProgressProvider) | none | Asked when the menu opens, never stored. Return null when you have no answer. It is not shown once the achievement is held, nor on a hidden one. A provider that throws is logged and skipped, so a bad one cannot break someone's menu. |
.progressUnit(TextProvider) | none | What the progress numbers count, so the bar reads 47 / 300 minutes instead of 47 / 300. Same TextProvider contract as the title, so the word is written in the reader's language. Ignored when there is no .progress(...). |
AchievementCategory.builder(key) takes .title(TextProvider), .icon(AchievementIcon) and
.order(int) with the same meaning. .title(...) is required there as well: without it, build()
throws.
Order does not matter. An achievement registered before its category is accepted
(UNKNOWN_CATEGORY) and displayed under its category key until the category arrives.
Registration is not frozen at startup. Register late, generate a family of achievements from whatever content you find, re-register to refresh a title. Registering an id that already exists updates its presentation and never touches an award a player already holds. It follows that the total number of achievements is not a constant, which is why nothing in the menu shows a percentage.
Withdrawโ
api.unregisterAll(this, "lepsu");
Called automatically when your plugin is disabled, so you only need it to swap one set of achievements for another at runtime. Awards survive: your achievements vanish from the menu, the players keep what they earned, and everything reappears if you register the same ids again.
Declaring what you remember, so a reset can erase itโ
If you persist anything an achievement of yours is computed from โ a tally, a relationship between two players, a set of things done โ you must declare it. Lasers-Enigma keeps its own database and cannot reach into yours, so a progress reset is a request rather than a cascade: it asks every declared store to clear itself.
public final class DuoPartnerStore implements PlayerProgressStore {
@Override public String name() { return "lepsu:duo_partner_victory"; }
@Override public void clearForPlayer(UUID player) {
repository.deleteByPlayer(player); // their rows only
}
@Override public void clearForPlayerInAreas(UUID player, Set<Integer> areaIds) {
// Nothing: a partnership belongs to no particular level.
}
@Override public void clearForArea(int areaId) {
// Same reason.
}
@Override public void clearAll() { repository.deleteAll(); }
}
api.registerProgressStore(this, new DuoPartnerStore());
Register it at enable, before claiming your namespace and regardless of whether the claim succeeds: a refused namespace stops your achievements existing, it does not stop rows an earlier version of your plugin already wrote. Whatever is on disk still has to be erasable.
Once declared, you are covered by every reset path โ the ones that exist and the ones added later.
Your store is dropped automatically when your plugin is disabled; unregisterProgressStores(this) does it
by hand, and touches no data.
Three rules worth stating plainly:
- Only your own rows. A tally between two players is two rows, one per player; erasing A's progress must not take away what B earned.
- Do nothing on the per-area methods when your data is not per-area. A lifetime tally survives one level being reset โ otherwise a player loses, on one puzzle's reset, something they earned across three.
- Clear and return. The methods run on the main thread, inside an administrative command. They may block on your own database; they are not the place to recompute anything.
Why a contract rather than a shared table: the plugins do not share a database, and they never will โ one uses SQLite or MySQL, the other its own embedded H2. A table could never have crossed that line. What can cross it is the obligation to clear.
Awardingโ
api.award("lepsu:chapter_one", playerUUID); // they just did it
api.award("lepsu:chapter_one", playerUUID, AwardCause.OWNER_RECOGNISED); // they had already done it
Idempotent, and safe for an offline player.
Use the recognition form for a fact that was already true โ a campaign finished long before the achievement existed, a rating that has only now become established. A recognised award is presented to the player as coming from their history and is never announced to the rest of the server. Without it, the day you ship a batch of achievements every veteran's backlog would land in chat at once.
What award() does for you, and what it does notโ
Applied by award() | Left to you |
|---|---|
| The server-wide on/off switch | Whether the player deserves it |
| Unlocking once and once only | Any difficulty floor or eligibility rule |
| Telling the player, and the server | Refusing a run another player shared |
| Recording who owned the achievement | Refusing a puzzle that was already solved |
The right-hand column is not an oversight. Those rules are about how a puzzle was played, and only
Lasers-Enigma sees that; by the time you call award() the run is over. So when your catalogue entry says
"does not count on a shared run", you must check it โ the engine cannot.
What the calls returnโ
Every call answers with an enum rather than an exception, and a companion plugin is expected to branch on
it. All four live in eu.lasersenigma.achievement.api.
ClaimResult, from claimNamespace:
| Value | What it means for you |
|---|---|
CLAIMED | The namespace is now yours. |
ALREADY_YOURS | You held it already; nothing changed. Claiming twice is harmless. |
TAKEN | Another plugin holds it. Pick a different one โ you cannot register anything under it. |
RESERVED | le belongs to Lasers-Enigma and is never available. |
INVALID | The namespace is not lowercase letters, digits and underscores. |
RegisterResult, from register and registerCategory:
| Value | What it means for you |
|---|---|
REGISTERED | Registered for the first time. |
UPDATED | An existing registration was refreshed. Awards already granted were untouched. |
UNKNOWN_CATEGORY | Registered, but its category is not known yet. Harmless: the achievement shows under its category key until the category arrives. |
INVALID_ID | Refused: the id does not match namespace:name, or the name uses illegal characters. |
NAMESPACE_NOT_CLAIMED | Refused: nobody has claimed that namespace. Call claimNamespace first. |
NOT_YOURS | Refused: another plugin holds that namespace. Almost always a typo in your id โ check it before assuming a conflict. |
FOREIGN_PREREQUISITE | Refused: the prerequisite lives in another namespace, which is not supported. |
AwardResult, from both award overloads:
| Value | What it means for you |
|---|---|
NEW | The player did not hold it and now does. It has been announced. |
ALREADY | The player already held it. Nothing changed, nothing was announced โ the ordinary answer to a repeated award, not an error. |
DISABLED | Achievements are turned off on this server, so nothing was recorded. |
UNKNOWN_ID | No achievement is registered under that id. |
FAILED | The write failed. Nothing was recorded, nothing was announced, and the cause was logged on the server. Worth trying again โ and never to be lumped in with ALREADY, which would lose the unlock for good. |
UnregisterResult, from unregisterAll:
| Value | What it means for you |
|---|---|
DONE | The namespace was cleared. Awards players hold survive. |
NOT_YOURS | Refused: that namespace belongs to another plugin. |
UNKNOWN | Nothing to do: no such namespace was ever claimed. |
Readingโ
api.isUnlocked("lepsu:chapter_one", playerUUID);
api.unlocked(playerUUID); // every id they hold, including ones no longer registered
api.unlockedAt("lepsu:chapter_one", playerUUID);
All three work for an offline player: an online player's achievements are held in memory, an offline one's cost a single read.
Writing the textsโ
A title and a description are supplied as a TextProvider, and two different people are involved:
(subject, viewer) -> translate(viewer, "chapter_one.title")
The subject is the player the text is about; the viewer is the player reading it. They differ when
one player's unlock is announced to everyone else, and when an administrator lists someone else's
achievements โ the title must then be written in the reader's language while describing the earner.
viewer is null when there is no reader, for a console listing or a log line.
Called on the main thread, once per reader. Keep it cheap: no database access, no scheduling.
Whatever you return is treated as plain text. Formatting codes are stripped, line breaks collapse, and the result is capped at 64 characters for a title and 256 for a description. A provider that throws costs you the text, not the unlock: the id is shown instead. All of that exists because one of these strings ends up in a line sent to every player on the server.
Progressโ
.progress(playerUUID -> new AchievementProgress(solved, 12))
Called on the main thread, while the menu is being drawn, once per achievement on the page. Progress is computed then and never stored โ storing it would mean a database write on every step a player takes. So it must be cheap: read something you already hold in memory, or at worst one indexed row. Never iterate over content, never go off to another service, and never block.
An achievement whose progress cannot be computed cheaply simply has no progress bar. That is a perfectly good outcome, not a limitation to work around.
Naming the unitโ
.progress(playerUUID -> new AchievementProgress(minutesPlayed(playerUUID), 300))
.progressUnit((subject, viewer) -> translate(viewer, "unit.minutes"))
Set it whenever the number the player sees is not the number your description names. A description
asking for five hours, backed by a bar counted in minutes, reads 47 / 300 โ which looks like a bug until
it reads 47 / 300 minutes. Lasers-Enigma's own thresholds hit this twice, time in minutes and distance
in blocks, which is why the option exists.
The unit is a TextProvider like the title, so it is written in the reader's own language. Leave it out
when the title already makes the unit obvious: a bare x / N is the better bar then.
Reacting to an unlockโ
@EventHandler
public void onUnlocked(AchievementUnlockedEvent event) { ... }
Fired synchronously, on the main thread, right after the unlock is recorded โ whatever caused it, yours or Lasers-Enigma's. Two consequences:
- A backfill at join fires one event per achievement in the same tick, so a listener that hands out a reward must be safe to run several times in a row.
- The unlock is already recorded when the event fires, so a listener that throws costs the player nothing.
The player may be offline: an administrator can grant an achievement to someone who is not connected.
Trust: what namespace ownership is, and is notโ
Bukkit plugins share one JVM. A plugin installed on the server can always reach Lasers-Enigma through reflection, whatever this API says. Namespace ownership catches mistakes between well-behaved plugins โ two plugins picking the same id, a plugin withdrawing another's achievements by accident. It is not a defence against a hostile plugin, and it was never meant to be.
Registering is namespace-checked; awarding deliberately is not. register, registerCategory and
unregisterAll take your plugin and refuse a namespace you do not hold, because those calls describe
achievements and a mistyped namespace would rewrite someone else's. award takes no plugin at all: any
plugin may award any registered achievement, Lasers-Enigma's own included. That is a decision, not an
oversight โ an achievement is a fact about a player, the plugin best placed to observe a fact is not
always the one that defined it, and a claim that is admittedly not a security boundary would buy nothing
here beyond friction.
One rule follows from all of it, and it matters:
โ ๏ธ A content gate must re-check the underlying fact. If unlocking an achievement is what opens a door, ask the question the achievement asks โ do not trust
isUnlockedalone. An award can be handed out by an administrator, recognised from an old fact, or pushed by another plugin entirely โ awarding is open to every plugin, including for achievements you defined.
Stabilityโ
- Everything in
eu.lasersenigma.achievement.apiis API; nothing else is. - Definitions and categories are built through builders, so a new optional trait never breaks a caller.
- Ids never change once released. They are written on every award row and outlive both content and plugins.
- A breaking change to this package ships as a breaking version of Lasers-Enigma, with the companion plugins adapted in the same change set.
Related pagesโ
- Achievements โ what a player sees.
- Events โ the other events a companion plugin can listen to.
- Direct-call โ reaching Lasers-Enigma's internals, and why this API does not.