Skip to main content

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(), not build(). The builders still validate what they are handed, and they do it by throwing: AchievementDefinition.builder(id).build() raises IllegalStateException when no title or no description was supplied, and AchievementCategory.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)DefaultMeaning
.title(TextProvider)requiredThe 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)requiredThe 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)noneThe tab it appears under. A definition with no category lands in <your namespace>:other rather than nowhere.
.icon(AchievementIcon)a paper sheetAchievementIcon.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)COMMONPresentation only: the colour the title is written in.
.order(int)0Sort position inside the category; ties break on the id, so an unordered set is still stable.
.hidden(boolean)falseA 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)noneOrdering 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)noneAsked 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)noneWhat 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 switchWhether the player deserves it
Unlocking once and once onlyAny difficulty floor or eligibility rule
Telling the player, and the serverRefusing a run another player shared
Recording who owned the achievementRefusing 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:

ValueWhat it means for you
CLAIMEDThe namespace is now yours.
ALREADY_YOURSYou held it already; nothing changed. Claiming twice is harmless.
TAKENAnother plugin holds it. Pick a different one โ€” you cannot register anything under it.
RESERVEDle belongs to Lasers-Enigma and is never available.
INVALIDThe namespace is not lowercase letters, digits and underscores.

RegisterResult, from register and registerCategory:

ValueWhat it means for you
REGISTEREDRegistered for the first time.
UPDATEDAn existing registration was refreshed. Awards already granted were untouched.
UNKNOWN_CATEGORYRegistered, but its category is not known yet. Harmless: the achievement shows under its category key until the category arrives.
INVALID_IDRefused: the id does not match namespace:name, or the name uses illegal characters.
NAMESPACE_NOT_CLAIMEDRefused: nobody has claimed that namespace. Call claimNamespace first.
NOT_YOURSRefused: another plugin holds that namespace. Almost always a typo in your id โ€” check it before assuming a conflict.
FOREIGN_PREREQUISITERefused: the prerequisite lives in another namespace, which is not supported.

AwardResult, from both award overloads:

ValueWhat it means for you
NEWThe player did not hold it and now does. It has been announced.
ALREADYThe player already held it. Nothing changed, nothing was announced โ€” the ordinary answer to a repeated award, not an error.
DISABLEDAchievements are turned off on this server, so nothing was recorded.
UNKNOWN_IDNo achievement is registered under that id.
FAILEDThe 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:

ValueWhat it means for you
DONEThe namespace was cleared. Awards players hold survive.
NOT_YOURSRefused: that namespace belongs to another plugin.
UNKNOWNNothing 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 isUnlocked alone. 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.api is 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.
  • 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.