Action Limit¶
Overview¶
The Action Limit system enforces time-windowed usage caps on specific game actions — exchanges, dungeon entries, interactions, or any custom action identified by a FGameplayTag. Limits can be scoped per character or per account, and reset automatically based on a configurable window (daily, weekly, or a custom interval).
{% hint style="info" %} Key traits
- All consumption and checks are server-authoritative.
- Resets are automatic — no background job needed; the window is computed from the current UTC timestamp at check time.
- Limits are defined in
DefaultGame.inias aTMap<FGameplayTag, FMIPActionLimitConfig>— no data assets required. - State is persisted via the save system and synced to the client via a compact delta RPC. {% endhint %}
Key Classes & Files¶
All paths are relative to Plugins/ModularInventoryPlus/Source/ModularInventoryPlus/.
| Class / Struct | File |
|---|---|
UMIPBaseActionLimitComponent |
Public/ActionLimit/MIPBaseActionLimitComponent.h/.cpp |
UMIPCharacterActionLimitComponent |
Public/ActionLimit/MIPCharacterActionLimitComponent.h/.cpp |
UMIPFamilyActionLimitComponent |
Public/ActionLimit/MIPFamilyActionLimitComponent.h/.cpp |
UMIPActionLimitManagerComponent |
Public/ActionLimit/MIPActionLimitManagerComponent.h/.cpp |
UMIPActionLimitSettings |
Public/ActionLimit/MIPActionLimitSettings.h |
FMIPActionLimitConfig |
Public/ActionLimit/MIPBaseActionLimitComponent.h |
FMIPActionUsageState |
Public/ActionLimit/MIPBaseActionLimitComponent.h |
FMIPActionLimitRequirement |
Public/ActionLimit/MIPActionLimitManagerComponent.h |
Data Structures¶
Enums¶
// Which save/limit bucket the action belongs to
UENUM(BlueprintType)
enum class EMIPActionLimitScope : uint8
{
Character, // Limit is per character
Account // Limit is shared across all characters on the account
};
// How the time window resets
UENUM(BlueprintType)
enum class EMIPResetType : uint8
{
DailyUTC, // Resets at UTC midnight each day
WeeklyUTC, // Resets at UTC midnight each Monday
EveryNHoursUTC, // Resets every N hours from UTC epoch
EveryNMinutesUTC, // Resets every N minutes from UTC epoch
EveryNSecondsUTC // Resets every N seconds from UTC epoch
};
FMIPActionLimitConfig¶
Defined in DefaultGame.ini, one entry per action tag.
USTRUCT(BlueprintType)
struct FMIPActionLimitConfig
{
int32 MaxUses = 0;
EMIPResetType ResetType = EMIPResetType::DailyUTC;
int32 IntervalHours = 1; // Used by EveryNHoursUTC
int32 IntervalMinutes = 1; // Used by EveryNMinutesUTC
int32 IntervalSeconds = 1; // Used by EveryNSecondsUTC
EMIPActionLimitScope Scope = EMIPActionLimitScope::Character;
};
FMIPActionUsageState¶
Persisted in the save file per action tag.
USTRUCT(BlueprintType)
struct FMIPActionUsageState
{
int32 UsedCount = 0; // Times used in the current reset window
int64 ResetKey = -1; // Opaque window identifier (derived from UTC timestamp)
};
FMIPActionLimitRequirement¶
Embedded in any system that wants to gate on an action limit (exchange, dungeon, interaction).
USTRUCT(Blueprintable)
struct FMIPActionLimitRequirement
{
FGameplayTag ActionTag; // Must match a key in DefaultActionLimitConfigs
};
FMIPActionUsageRepDelta¶
Compact struct sent from server to client when a consumption occurs.
USTRUCT(BlueprintType)
struct FMIPActionUsageRepDelta
{
FGameplayTag ActionId;
int32 UsedCount = 0;
int64 ResetKey = -1;
};
Component Architecture¶
UMIPBaseActionLimitComponent (Abstract)
├── UMIPCharacterActionLimitComponent → SaveIdentifier: ActionLimit_Character
│ Accessibility: Character
└── UMIPFamilyActionLimitComponent → SaveIdentifier: ActionLimit_Family
Accessibility: Account (family)
UMIPActionLimitManagerComponent
Sits on the PlayerController.
Holds cached refs to both scoped components.
Routes CanUseAction / TryConsumeAction to the correct one
based on the action's configured EMIPActionLimitScope.
{% hint style="info" %}
UMIPActionLimitManagerComponent is the only entry point callers should use. It resolves scope automatically — callers never need to know which backing component handles a given tag.
{% endhint %}
Configuration¶
Limits are declared in Config/DefaultGame.ini under UMIPActionLimitSettings:
[/Script/ModularInventoryPlus.MIPActionLimitSettings]
+DefaultActionLimitConfigs=((TagName="ActionLimit.Vendor.James.Apple"),(MaxUses=10))
+DefaultActionLimitConfigs=((TagName="ActionLimit.Dungeon.EndlessColosseum"),(MaxUses=3))
+DefaultActionLimitConfigs=((TagName="ActionLimit.Exchange.James.Ranger.Bow"),(MaxUses=5,ResetType=EveryNMinutesUTC,IntervalMinutes=30))
At runtime, configs are fetched via:
Reset Window Logic¶
The "window" concept is purely arithmetic — no timer or scheduled job exists. Every check derives a ResetKey from the current UTC Unix timestamp:
int64 MakeResetKey(int64 UnixUtcSeconds, const FMIPActionLimitConfig& Config)
{
switch (Config.ResetType)
{
case DailyUTC: return UnixUtcSeconds / 86400;
case WeeklyUTC: return MakeWeeklyKey_MondayUtc(UnixUtcSeconds);
case EveryNHoursUTC: return UnixUtcSeconds / (Config.IntervalHours * 3600LL);
case EveryNMinutesUTC: return UnixUtcSeconds / (Config.IntervalMinutes * 60LL);
case EveryNSecondsUTC: return UnixUtcSeconds / Config.IntervalSeconds;
}
}
Auto-reset: Before every check or consume, the stored state is normalized:
void NormalizeStateForNow(FMIPActionUsageState& State, const FMIPActionLimitConfig& Config, int64 Now)
{
const int64 KeyNow = MakeResetKey(Now, Config);
if (State.ResetKey != KeyNow)
{
State.ResetKey = KeyNow;
State.UsedCount = 0; // Window rolled over — counter reset automatically
}
}
No explicit reset call is ever needed.
Core Functions¶
On UMIPBaseActionLimitComponent¶
// Read-only check. Does not mutate state.
bool CanUseAction(FGameplayTag ActionId, int64 InAmount, int32& OutRemaining) const;
// Consumes InAmount uses. Server-only. Returns false if limit would be exceeded.
bool TryConsumeAction(FGameplayTag ActionId, int64 InAmount, int32& OutRemaining);
// Query remaining uses for the current window.
bool GetRemainingUses(FGameplayTag ActionId, int32& OutRemaining);
// Preview how many would remain after a consume (without actually consuming).
bool GetRemainingUsesAfterConsume(FGameplayTag ActionId, int64 InAmount, int32& OutRemainingAfter) const;
// Returns the configured max uses.
bool GetMaxUses(FGameplayTag ActionId, int32& OutMaxUses) const;
// Returns the Unix UTC timestamp when the current window ends.
bool GetNextResetUnixUtc(FGameplayTag ActionId, int64& OutNextResetUnixUtc);
// Returns a human-readable display string for UI (e.g. "3 / 5 remaining").
bool GetActionLimitDisplayText(FGameplayTag ActionId, FString& OutDisplayText);
On UMIPActionLimitManagerComponent (preferred entry point)¶
// Routes to the correct scoped component based on the action's configured Scope.
bool CanUseAction(const FMIPActionLimitRequirement& Req, int64 InAmount, int32& OutRemaining) const;
bool TryConsumeAction(const FMIPActionLimitRequirement& Req, int64 InAmount, int32& OutRemaining);
bool GetActionLimitDisplayText(FGameplayTag ActionId, FString& OutText);
Delegates¶
Declared on UMIPBaseActionLimitComponent, accessible via the manager:
// Fires on the server when a consume succeeds.
FMIPActionLimitConsumed OnActionConsumed;
// Params: (FGameplayTag ActionId, int32 RemainingAfter)
// Fires on the client when the server sends a state delta.
FMIPActionLimitStateUpdated OnActionLimitStateUpdated;
// Params: (FGameplayTag ActionId, int32 UsedCount, int32 Remaining)
Access via the manager component:
ManagerComp->GetActionLimitConsumed(ActionTag).AddDynamic(...);
ManagerComp->GetActionLimitStateUpdated(ActionTag).AddDynamic(...);
Flow: Action Attempted → Limit Enforced¶
{% tabs %} {% tab title="Overview" %}
Caller (exchange / dungeon / interaction)
│
├─ [Client] CanUseAction → early UI feedback
│
└─ [Server RPC]
│
├─ CanUseAction → re-validates (double-check)
│
└─ TryConsumeAction
│
├─ Within limit → increment counter → send delta to client → continue action
│
└─ Exceeded → return false → caller aborts, nothing is consumed
{% tab title="Detailed Steps" %}
[Client]
1. Caller calls CanUseAction(Req, Amount, OutRemaining)
└─ Manager routes to scoped component
└─ Component normalizes state (auto-resets if window changed)
└─ Returns true/false + OutRemaining for UI
2. If allowed, client fires Server RPC
[Server RPC Handler]
3. Server re-validates with CanUseAction (prevents race / exploits)
4. Server calls TryConsumeAction(Req, Amount, OutRemaining)
└─ Manager routes to scoped component
└─ NormalizeStateForNow() — auto-reset if window rolled
└─ Remaining = MaxUses - State.UsedCount
│
├─ Remaining < Amount
│ └─ return false → caller returns early, no side effects
│
└─ Remaining >= Amount
└─ State.UsedCount += Amount
└─ OnActionConsumed.Broadcast(ActionId, NewRemaining)
└─ ClientApplyActionLimitDelta(FMIPActionUsageRepDelta{...})
└─ MarkForSave()
└─ return true → caller continues action
[Client RPC - ClientApplyActionLimitDelta]
5. Client updates its local ActionStates map
6. OnActionLimitStateUpdated.Broadcast(ActionId, UsedCount, Remaining)
└─ UI widgets listen here to refresh displayed remaining uses
Integration Points¶
The FMIPActionLimitRequirement struct is embedded in any system that needs to gate on a limit:
| System | Where the requirement lives |
|---|---|
| Exchange | FMIPExchangeItemInfo::ActionLimitRequirement |
| Dungeon Entry | FMIPEnterDungeonInfo::ActionLimitRequirement |
| Interaction | InteractionInstanceDefinitionStruct::ActionLimitTag (gameplay tag directly) |
Each system follows the same pattern: check before RPC, re-check on server, consume on server.
Save & Replication¶
Save:
ActionStates(TMap<FGameplayTag, FMIPActionUsageState>) is markedSaveGameand persisted viaUMIPBaseSaveableComponent.- Character-scoped state is saved per character save slot.
- Account-scoped (Family) state is saved to the account/family save slot, shared across characters.
- On load, the server iterates all saved states and pushes a delta RPC to the owning client so it has accurate remaining counts immediately.
Network:
- All mutation (
TryConsumeAction) is server-only — guarded byROLE_Authority. - Client receives
FMIPActionUsageRepDelta(ActionId + UsedCount + ResetKey) via a reliable client RPC after each successful consume. - Client's local
ActionStatesmap is kept in sync and used only for display/read purposes.
{% hint style="warning" %}
Never call TryConsumeAction on the client. It is a no-op below authority and will not replicate.
{% endhint %}
End-to-End Diagram¶
┌─────────────────────────────────────────────────────────────────┐
│ DefaultGame.ini │
│ │
│ ActionLimit.Dungeon.EndlessColosseum → MaxUses=3, Daily │
│ ActionLimit.Exchange.Ranger.Bow → MaxUses=5, Every30min │
│ │
│ Loaded into UMIPActionLimitSettings at startup │
└─────────────────────────────────────────────────────────────────┘
│
(action is attempted)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ UMIPActionLimitManagerComponent │
│ (on PlayerController) │
│ │
│ CanUseAction(Req, Amount) │
│ └─ Looks up Scope from config │
│ └─ Routes to Character or Family component │
│ └─ NormalizeState (auto-reset if window changed) │
│ └─ Returns true / false + OutRemaining │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ Within limit │ Exceeded
▼ ▼
[Server RPC fires] Caller aborts early
│ No state changed
▼
┌─────────────────────────────┐
│ TryConsumeAction (server) │
│ │
│ • Auto-reset if needed │
│ • Increment UsedCount │
│ • Broadcast OnActionConsumed│
│ • Send delta RPC to client │
│ • MarkForSave │
└─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Client — ClientApplyActionLimitDelta │
│ │
│ Updates local ActionStates map │
│ Broadcasts OnActionLimitStateUpdated → UI refreshes │
└─────────────────────────────────────────────────────────────────┘