-
Notifications
You must be signed in to change notification settings - Fork 1
Java ECS System EN
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.
- 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
Worldlimiter plus aPlayerlimiter, or severalMoneyCostComponententries in differentgroup_idgroups. -
Conditions are shared by purchasing and UI — everything that extends
ConditionComponentparticipates in purchase checks;RenderHideComponentuses the same conditions to hide unavailable offers. -
Smart Sync —
shouldSync()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 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.
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. |
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 |
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.
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.
Rewards are granted after successful payment. If granting a reward throws an error, the processor rolls back already charged CostComponent entries in reverse order.
The promo pipeline works like this:
-
PromoComponentcollects activepromo_idvalues. -
PromoEffectComponententries are selected bytarget_promo_idandapply_groups. - Effects are sorted by
priority. - Each effect changes the price through
PromoPriceContext. - Script/server price events run after effects.
- The price is sanitized before charging.
Shared promo fields:
-
promo_id— active promo ID. -
scope—GLOBALorPLAYER.
Shared effect fields:
-
target_promo_id— the promo this effect is bound to. -
priority— application order. -
apply_groups— payment groups affected by the effect.
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);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.
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
ShopLimitersreset 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);