Skip to content

Java ECS System EN

Bogdan edited this page Aug 5, 2026 · 1 revision

Java: ECS System

SDM Shop 2 uses an ECS-style approach: a shop, category, and offer are ShopEntity instances, and behavior is defined by a set of components. Instead of a rigid class hierarchy, an offer is assembled from small independent blocks: cost, reward, condition, promo, and rendering settings.

Key Ideas

  • Composition instead of inheritance — one offer can have multiple costs, rewards, conditions, limiters, and promo effects.
  • Components of the same type can repeat — for example, a World limiter plus a Player limiter, or several MoneyCostComponent entries in different group_id groups.
  • Conditions are shared by purchasing and UI — everything that extends ConditionComponent participates in purchase checks; RenderHideComponent uses the same conditions to hide unavailable offers.
  • Smart SyncshouldSync() decides whether a component is sent to the client. Server checks such as script conditions can remain server-only.
  • Declarative serialization — each component declares its JSON/network fields through ComponentSerializer.

ShopEntity

ShopEntity stores components and manages their lifecycle.

Method Returns Description
addComponent(T component) T Adds a component, assigns its root, sorts by priority(), and triggers an update.
hasComponent(Class<?> type) boolean Checks whether a component of the given class or subclass exists.
getComponent(Class type) Optional Returns the first matching component.
getComponents() ObjectList<ShopComponent> Returns an immutable list of all components.
getComponents(Class type) ObjectList<T> Returns a cached immutable list of matching components. Uses Class#isInstance, so subclasses are included.
serialize() / deserialize(...) JSON Saves/loads components.
serializeNetwork(...) / deserializeNetwork(...) network Sends only components where shouldSync() == true.

Practical result: after LimiterComponent became a subclass of ConditionComponent, offer.getComponents(ConditionComponent.class) automatically includes limiters.

ShopComponent

Base class for every component.

Method Returns Description
init() void Called after the component is attached to a root and all components are loaded.
priority() int Component order inside the entity. Lower values run earlier.
getType() IComponentType<?> Component type for registry and serialization.
getCategory() ShopComponentCategory Category used by the editor/grouping UI.
shouldSync() boolean If false, the component is not sent to clients.
getRoot() ShopEntity Entity this component is attached to.
getRoots<T>() T Same as getRoot(), but cast to the requested type.
invokeUpdate() void Marks the component dirty and notifies the root.
additionalSerializer() ComponentSerializer<?> Additional fields shared by a component family. For example, promo components add promo_id and scope.

Component Categories

Categories affect the editor UI and help group components.

Category Base Class Examples
CONDITION ConditionComponent condition_limiter, condition_cooldown, condition_script
COST CostComponent cost_money
REWARD RewardComponent reward_item, reward_money, reward_command, reward_script
PROMO PromoComponent promo_time, promo_weekly_time, promo_cooldown, promo_trigger
PROMO_EFFECT PromoEffectComponent discount, price_modifier
MISC ShopComponent name, catalog, hide_render, containers

ConditionComponent

Conditions are used by the server purchase processor and the client UI.

Method Returns Description
isChecked(Player player) boolean Main condition check.
verifiedOnClient() boolean true if the condition can be checked on the client. false means the UI asks the server for the state.
recordPurchase(Player player, int amount) void Hook after a successful purchase. Used by cooldown components to store lastPurchaseTime.

If an offer has RenderHideComponent, the UI hides the offer when any condition returns false.

CostComponent

Base class for costs. The shared serializer adds the group_id field.

Purchasing selects only cost components from the chosen group. If group_id is empty, it belongs to the default group. Multiple costs in the same group are charged together.

RewardComponent

Rewards are granted after successful payment. If granting a reward throws an error, the processor rolls back already charged CostComponent entries in reverse order.

PromoComponent and PromoEffectComponent

The promo pipeline works like this:

  1. PromoComponent collects active promo_id values.
  2. PromoEffectComponent entries are selected by target_promo_id and apply_groups.
  3. Effects are sorted by priority.
  4. Each effect changes the price through PromoPriceContext.
  5. Script/server price events run after effects.
  6. The price is sanitized before charging.

Shared promo fields:

  • promo_id — active promo ID.
  • scopeGLOBAL or PLAYER.

Shared effect fields:

  • target_promo_id — the promo this effect is bound to.
  • priority — application order.
  • apply_groups — payment groups affected by the effect.

Registering a Component

Registration is performed once during initialization:

ShopComponentRegistry.register(MoneyCostComponent.TYPE);
ShopComponentRegistry.register(LimiterComponent.TYPE);

A component type usually extends SerializedComponentType<T> and declares a ComponentSerializer.

private static final ComponentSerializer<MyComponent> SERIALIZER =
        ComponentSerializer.<MyComponent>create()
                .addRequired("value", FieldCodecs.INT, MyComponent::getValue, MyComponent::setValue);

Offer Example

ShopOffer offer = ShopOffer.create(UUID.randomUUID(), true);

offer.addComponent(new NameComponent("Epic Sword"));
offer.addComponent(new MoneyCostComponent(ResourceLocation.tryBuild("sdm", "coins"), 1000.0D));
offer.addComponent(new ItemRewardComponent(Items.DIAMOND_SWORD.getDefaultInstance(), 1));
offer.addComponent(new LimiterComponent(LimiterComponent.LimiterType.Player, 1, 86_400_000L, "daily_player"));

offer.initializeServerOnlyComponents();

for (ConditionComponent condition : offer.getComponents(ConditionComponent.class)) {
    if (!condition.isChecked(player)) {
        return;
    }
}

For real purchases, prefer ShopTransactionProcessor, because it atomically checks conditions, limits, costs, rewards, payment rollback, and network sync.

Limiters

LimiterComponent remains a separate component with a limiter API, but now extends ConditionComponent. This means:

  • purchasing still checks limits for the requested amount;
  • the UI can hide the offer through RenderHideComponent;
  • multiple limiters of the same type can be separated through limit_key;
  • reset commands and ShopLimiters reset storage and sync clients.

Use the ShopLimiters facade from external code:

ShopLimiters.canPurchase(offer, player, amount);
ShopLimiters.recordPurchase(offer, player, amount);
ShopLimiters.getAvailable(offer, player);
ShopLimiters.getSnapshot(offer, player);
ShopLimiters.resetOffer(offer);
ShopLimiters.resetWorld(offer);
ShopLimiters.resetPlayer(offer, player);

Clone this wiki locally