For Developers/API Addons·匠

API Addons

New in Cultivation 0.5.0.

Events let you reshape what Cultivation does. Registries let you add content it treats as its own. The three hooks on this page go further: they let another mod take over a whole layer of Cultivation and keep everything else running on top.

HookReplaces
ProgressionProviderThe entire realm / stage / Qi ladder.
CultivationThemeEvery word the mod shows a player.
AdminConfigSectionNothing -- it adds your own settings to Cultivation's admin menus.

All three are installed from your plugin's setup(), in any load order relative to Cultivation's own. Nothing reads them until a player meditates, opens a menu, or trips a realm gate, which is long after every plugin has loaded.

ProgressionProvider

CultivationAPI.setProgressionProvider(@Nullable ProgressionProvider provider)
CultivationAPI.getProgressionProvider()

Hands your mod the realm/stage/Qi ladder. From the moment one is installed, every command, UI page, HUD line and gameplay gate in Cultivation reads its numbers from you instead of from the player's CultivationComponent: /cultivation shows your rank names, the HUD shows your XP bar, the meditation ritual runs your rank-ups.

Everything else in the mod keeps working. Sects, the Dao, techniques, spirit beasts, formations, cave abodes, duels, alchemy, refinement and the skill tree all sit on top of whatever ladder is installed.

The methods you implement

plugin.siren.API.ProgressionProvider has 22 methods. Seventeen are abstract and you must write them; the other five have defaults. Every one is handed the ComponentAccessor<EntityStore> accessor and Ref<EntityStore> ref of the player being asked about (shown as accessor, ref), except where noted.

MethodReturnsWhat it is for
getId()StringA stable, unique id namespaced with your mod's name ("SoulRings:spirit_power"). Used for logging and so /cultivation admin can report which system is live.
getLevel(accessor, ref)intThe player's flat, ever-increasing power number. Drives the max-health and damage curves, the cross-player rankings, and anywhere a single "how strong is this cultivator" scalar is needed. It does not have to match the built-in 0 to 27 scale.
getMaxLevel()intThe highest level obtainable, for progress bars and "x / y" displays. Takes no player.
isMaxLevel(accessor, ref)booleanWhether the player is fully maxed out. Once true, meditation stops granting progress and both rituals stop being offered.
getProgress(accessor, ref)floatThe player's banked progress toward the next level. The built-in system shows this as "Qi".
getProgressRequiredForNext(accessor, ref)floatWhat the next level costs, after any discounts of your own. Return Float.MAX_VALUE at max level.
addProgress(accessor, ref, float amount, PlayerRef playerRef)voidBanks progress from one of Cultivation's own sources - meditating on a vein, a Devil-path kill, an admin addqi. The amount has already been through the race, skill tree, pill, sect and Yin-Yang multipliers and the cancellable PreQiGainEvent. Do nothing to refuse the source entirely. playerRef is nullable.
getRankLabel(accessor, ref)MessageWhat to show where the built-in system shows a realm name: a rank title, a class, a tier. Appears in the HUD, the stats page, /cultivation info and the rankings.
getSubRankLabel(accessor, ref)Message, nullableWhat to show where it shows a stage name - typically the level within the rank. Return null to show nothing in that slot.
getEquivalentRealm(accessor, ref)CultivationRealmWhich of the seven realms this player counts as having reached, for every realm gate (see below).
isReadyForBreakthrough(accessor, ref)booleanWhether the player has banked enough to attempt the milestone ritual. Tested first.
isReadyForAdvancement(accessor, ref)booleanWhether the player has banked enough to attempt the routine ritual.
getBreakthroughDurationSeconds(accessor, ref)floatHow long the milestone ritual takes, in seconds of uninterrupted meditation.
getAdvancementDurationSeconds(accessor, ref)floatHow long the routine ritual takes.
completeBreakthrough(accessor, ref, PlayerRef playerRef)voidThe milestone ritual finished: spend the banked progress and rank the player up. Cultivation still handles what surrounds it - skill points, the celebration particle, the HUD refresh and BreakthroughEvent - so you only move your own numbers and tell the player.
completeAdvancement(accessor, ref, PlayerRef playerRef)voidThe routine ritual finished. Same contract.
demote(accessor, ref, PlayerRef playerRef, boolean wasBreakthrough)voidA ritual was failed - walked out of, or lost to Qi Deviation. The built-in system drops the player one sub-stage and wipes banked Qi; do whatever the equivalent punishment is for yours.
getRankLabelForLevel(int level)Message, default nullThe rank label for a player who is not loaded. The rankings list every cultivator who has ever been seen, including everyone offline, so they have only a recorded level to go on. Implement it whenever level alone determines the rank; the default leaves the rankings falling back to the realm names recorded alongside the level.
getSubRankLabelForLevel(int level)Message, default nullThe offline counterpart of getSubRankLabel. Only consulted when the method above returned a label, since the two fill the halves of one "rank (subrank)" line.
supportsProfiles()boolean, default falseWhether you keep your own progression in step with Cultivation's profiles. The default refuses profile switching while you are installed - see below.
getHealthBonus(accessor, ref, float configuredHealthPerLevel)float, default level * configuredHealthPerLevelAdditive max health for this player's progression. Applied as a keyed stat modifier, so a smaller number later correctly shrinks the bonus. Override only if your levels want a different shape.
getDamageMultiplier(accessor, ref, float configuredDamagePercentPerLevel)float, default 1 + level * (percent / 100)The outgoing damage multiplier from progression alone. Race, skill tree and technique multipliers are applied on top of what you return, so return 1.0 for "no bonus", never 0.

A minimal provider

A complete provider that keeps one number of its own. LadderComponent is your own persisted component; the interface is deliberately stateless, so your component stays the single source of truth.

public final class MyLadder implements ProgressionProvider {

    private static final int MAX_LEVEL = 30;

    @Nonnull @Override
    public String getId(){
        return "MyMod:ladder";
    }

    // --- Level ---

    @Override
    public int getLevel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
        return ladder == null ? 1 : ladder.getLevel();
    }

    @Override
    public int getMaxLevel(){
        return MAX_LEVEL;
    }

    @Override
    public boolean isMaxLevel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return getLevel(accessor, ref) >= MAX_LEVEL;
    }

    // --- Progress ---

    @Override
    public float getProgress(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
        return ladder == null ? 0f : ladder.getXp();
    }

    @Override
    public float getProgressRequiredForNext(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return isMaxLevel(accessor, ref) ? Float.MAX_VALUE : 100f * getLevel(accessor, ref);
    }

    @Override
    public void addProgress(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
                            float amount, @Nullable PlayerRef playerRef){
        LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
        if(ladder == null || isMaxLevel(accessor, ref)){
            return;
        }
        ladder.addXp(amount);
        CultivationAPI.refreshProgression(accessor, ref);   // keep the HUD bar live
    }

    // --- Display ---

    @Nonnull @Override
    public Message getRankLabel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return Message.translation("server.mymod.rank." + (getLevel(accessor, ref) / 10));
    }

    @Nullable @Override
    public Message getSubRankLabel(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return Message.translation("server.mymod.label.level").param("level", String.valueOf(getLevel(accessor, ref)));
    }

    // --- Gating ---

    @Nonnull @Override
    public CultivationRealm getEquivalentRealm(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        CultivationRealm[] realms = CultivationRealm.values();
        return realms[Math.min(getLevel(accessor, ref) / 5, realms.length - 1)];
    }

    // --- Rituals: every tenth level is the milestone, the rest are routine ---

    private boolean banked(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref){
        return !isMaxLevel(accessor, ref) && getProgress(accessor, ref) >= getProgressRequiredForNext(accessor, ref);
    }

    @Override
    public boolean isReadyForBreakthrough(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return banked(accessor, ref) && getLevel(accessor, ref) % 10 == 9;
    }

    @Override
    public boolean isReadyForAdvancement(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return banked(accessor, ref) && getLevel(accessor, ref) % 10 != 9;
    }

    @Override
    public float getBreakthroughDurationSeconds(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return 60f;
    }

    @Override
    public float getAdvancementDurationSeconds(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref){
        return 30f;
    }

    @Override
    public void completeBreakthrough(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
                                     @Nullable PlayerRef playerRef){
        levelUp(accessor, ref);
    }

    @Override
    public void completeAdvancement(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
                                    @Nullable PlayerRef playerRef){
        levelUp(accessor, ref);
    }

    private void levelUp(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref){
        LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
        if(ladder != null){
            ladder.levelUp();     // spends the banked progress and adds a level
        }
    }

    @Override
    public void demote(@Nonnull ComponentAccessor<EntityStore> accessor, @Nonnull Ref<EntityStore> ref,
                       @Nullable PlayerRef playerRef, boolean wasBreakthrough){
        LadderComponent ladder = accessor.getComponent(ref, LadderComponent.getComponentType());
        if(ladder != null){
            ladder.setXp(0f);     // a failed ritual costs the banked progress, never a level
        }
    }
}

Install it once from your plugin's setup():

CultivationAPI.setProgressionProvider(new MyLadder());

The five defaulted methods are left out above on purpose: getHealthBonus and getDamageMultiplier already scale with getLevel, supportsProfiles() stays false until you handle profile switches, and the two ...ForLevel labels only matter once you want offline players ranked under your names.

Realm gates

Dozens of gates across the mod are written against CultivationRealm -- a technique's unlock realm, a race's, a spirit beast species' minimum realm, refinement tiers, abode quality, the Dao unlock. Rather than rewrite them, Cultivation asks your provider which realm the player counts as:

@Nonnull CultivationRealm getEquivalentRealm(ComponentAccessor<EntityStore> accessor, Ref<EntityStore> ref);

Map your own levels onto the seven realms however you like, and every existing gate understands your progression for free. A straight proportional split of your level range is usually right; returning a constant works too, but then every realm-gated feature in the mod is either permanently open or permanently shut.

Two kinds of rank-up

Cultivation drives rank-ups through its timed meditation ritual and offers two flavours. Map your progression onto them however suits:

FlavourRitual
AdvancementThe routine step. Shorter, lower Spirit Vein requirement, no tribulation lightning by default.
BreakthroughThe milestone step. Longer, higher vein requirement, tribulation lightning -- or the Heart-Devil Trial for a deeply-leaned cultivator.

Only one may be ready at a time; isReadyForBreakthrough is tested first. Returning true from neither simply means meditation does its ordinary thing that tick. You also supply the ritual durations yourself through getBreakthroughDurationSeconds and getAdvancementDurationSeconds, so the built-in timings do not apply to your ladder.

What you store

The interface is deliberately stateless -- every method is handed the accessor and ref of the player being asked about, so your own persisted component stays the single source of truth. The player's CultivationComponent still exists and is left completely alone, which is what lets a server uninstall your addon and get its original realms back untouched.

Threading. Every method is called on that player's world thread, and the accessor may be a CommandBuffer rather than a Store. Component creation must go through the accessor, not ref.getStore().putComponent -- the latter throws "Store is currently processing!" and takes the world down. Reads are fine either way.

Only one provider can be installed at a time. The last to register wins, and a warning naming both is logged, since two mods each believing they own progression is a misconfiguration rather than something to silently pick a winner for. Pass null to hand progression back to the built-in system.

Whenever your own code changes a player's level outside a rank-up, or their banked progress while a bar is on screen, call CultivationAPI.refreshProgression(accessor, ref). Cultivation cannot detect a change inside somebody else's component on its own, so anything that skips it leaves the HUD, the rankings and every realm gate showing the player's previous standing.

Profiles under a provider

v0.7.0 The interface gained one default method:

default boolean supportsProfiles(){
    return false;
}

And that default refuses profile switching outright while your provider is installed - the player is told another mod governs progression here. That is deliberate: a switch swaps the player's CultivationComponent, race, dao, skill tree and techniques underneath you, and a provider that has not planned for it would be left describing the wrong cultivator - half-performed is worse than refused.

To lift the refusal, do both of these and then return true:

Profile listeners are the one place in the API allowed to write components directly through the Store - they run on the world thread outside any ticking system, precisely so a provider can do this bookkeeping. See Events for the full ProfileEvents table. The one exception to the refusal is the operator's test sandbox, which is allowed even under a provider - it cannot change your addon's view of the player, and the command says so.

CultivationTheme

CultivationAPI.setTheme(@Nullable CultivationTheme theme)
CultivationAPI.getTheme()

Re-words the mod's entire player-facing vocabulary without touching its behavior, so a setting with soul masters and academies rather than cultivators and sects reads as its own game instead of a re-skin. Every string Cultivation shows a player -- chat, commands, HUD, menus, item names, tooltips -- is routed through your translate first, and whatever you return is used verbatim.

CultivationTheme has two methods:

@Nonnull String getId();                          // namespaced ("SoulRings:soul_land"); logging and /cultivation admin only
@Nullable Message translate(@Nonnull String key);   // the full key, always with its `server.` prefix

Return null from translate for any key you do not re-word and Cultivation's own wording is used, so a theme may cover as much or as little as it likes.

Why a hook and not a language file

Because the engine will not let you do it with one. Language files from every asset pack are merged into a single catalog, and that merge is first-writer-wins -- a duplicate key from a later pack is discarded and logged as 'x' has multiple definitions. Cultivation's own pack loads before any addon that depends on it, so an addon shipping server.cultivation.playerMsg.breakthrough in its own server.lang would be silently ignored, and even winning that race would only mean winning it on that particular boot.

So a theme keeps its strings under its own keys -- which collide with nothing -- and maps Cultivation's keys onto them.

Return a Message.translation(...) of your own key rather than Message.raw(...) wherever you can. Raw text is identical in every language, and Cultivation is translated.

translate is called on whatever thread is building the message, often several times per rendered line, so it must be fast, thread-safe and free of side effects -- a lookup in an immutable map is the intended shape. Any .param(...) placeholders Cultivation fills in are applied to whatever you return, so a replacement must keep the same placeholder names; drop one and it renders empty. Only one theme can be installed at a time; the last to register wins and a warning naming both is logged.

AdminConfigSection

CultivationAPI.registerAdminConfigSection(@Nonnull AdminConfigSection section)
CultivationAPI.unregisterAdminConfigSection(@Nonnull String sectionKey)
CultivationAPI.getAdminConfigSections()
CultivationAPI.getAdminConfigSection(@Nonnull String sectionKey)
CultivationAPI.newAdminConfigSection(String key, String labelKey, String hintKey, int sortOrder, Runnable save, List<AdminConfigField> fields)

Adds a group of tunable rows to Cultivation's admin menu, where it appears as its own section button on the Config tab of /cultivation admin beside Cultivation's own 53 -- with the same rows, the same editing, and the same single Save. The same settings also appear in a Mod Settings block at the bottom of the ordinary Cultivation settings menu, visible to admins only. The worked example, the five row factories and the tooltip rule are under Registries.

One section per config file is the shape Cultivation uses for itself and the one that reads best.

MemberPurpose
getKey()A stable id, unique across every registered section. Namespace it ("SoulRings:spiritPower"). What a click on the button sends back, so it is deliberately independent of list order.
getLabel()The section button and heading. Use a Message.translation(...) so it localizes.
getHint()One line explaining what the section tunes -- the button tooltip and the text under the heading.
getFields()The rows, in display order. Build them with the newAdmin...Field factories rather than implementing AdminConfigField yourself.
save()Persists the section after an admin presses Save.
getSortOrder()Default SORT_LAST (100000). Cultivation's own sections claim SORT_BUILTIN_FIRST (100) upward in steps of 100, so a value in between slots yours among them.
isVisible()Default true. Read on every render, so a section can hide itself - the Endless Leveling section does when that mod is not installed.
isShownInSettings()Default true. Return false (or wrap the section with CultivationAPI.adminPageOnly(section)) to keep it to the admin page alone, which is worth doing for a mod contributing several sections of real balance numbers.

A row has one of five kinds (AdminConfigField.Kind): NUMBER (a decimal field, the default), INT, BOOLEAN (a real checkbox), CHOICE (a dropdown over AdminConfigChoices) and TEXT. Before 0.6.1 only numbers existed, so a master switch had to masquerade as a 0/1 field; that is no longer necessary. Keep anything list-shaped in the config file.

Both host pages are gated on cultivation.admin, and the gate is re-checked server-side on save rather than trusted from the menu, so a section may safely expose real balance numbers.

Return a stable field list. The page matches an admin's in-flight edits to fields by key, so a list that changes shape between render and save silently drops those edits. Clamp out-of-range values in the field's set -- the page re-displays whatever get returns afterwards, so a coerced value is shown back to the admin rather than silently disagreeing with what they typed.

Registering the same section key twice replaces the first, so this is safe across a reload of your plugin.

Reading a player under any ladder

Three facade methods report a cultivator's standing under whichever system happens to be live, so your code does not need to know whether a provider is installed:

MethodReturns
CultivationAPI.getGlobalLevel(accessor, ref)The installed provider's level, or the built-in realm/stage flattened into one ever-increasing number.
CultivationAPI.getQi(accessor, ref)Banked progress toward the next rank-up -- the provider's progress if one is installed, else banked Qi.
CultivationAPI.refreshProgression(accessor, ref)Re-reads the provider's view of a player, after your own mod changes their level outside a rank-up.

CultivationAPI.getRealm(accessor, ref) returns the effective realm, which is the provider's equivalent realm while one is installed -- this is what every gate in the mod tests against. CultivationAPI.getStage(accessor, ref) is always null under a provider, since a replacement ladder has no sub-stages -- it has only its own getSubRankLabel. Prefer getGlobalLevel for anything numeric.

The rankings and /cultivation top sort on that flattened level, so a replacement progression ranks correctly against itself. For the built-in ladder the ordering is exactly what realm-then-stage always produced.