Skip to content

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.ini as a TMap<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:

GetDefault<UMIPActionLimitSettings>()->DefaultActionLimitConfigs.Find(ActionTag);


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
{% endtab %}

{% 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
{% endtab %} {% endtabs %}


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 marked SaveGame and persisted via UMIPBaseSaveableComponent.
  • 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 by ROLE_Authority.
  • Client receives FMIPActionUsageRepDelta (ActionId + UsedCount + ResetKey) via a reliable client RPC after each successful consume.
  • Client's local ActionStates map 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            │
└─────────────────────────────────────────────────────────────────┘