grantXp method

Future<SdkResult<PlayerProgressionSnapshot>> grantXp({
  1. required int amount,
  2. required String transactionId,
})

Grants amount XP under transactionId (idempotent — calling this again with the same id, even after a restart, is a no-op that returns the current snapshot). If this crosses one or more level thresholds, each newly-reached level's LevelDefinition.unlockRewardLines (if any) is granted exactly once through pipeline (when supplied), and onLevelUp fires once per level, in order — the level is capped at the curve's max level regardless of how much XP is granted past it.

Implementation

Future<SdkResult<PlayerProgressionSnapshot>> grantXp({
  required int amount,
  required String transactionId,
}) => _guard.runExclusive('progression', () async {
  if (amount <= 0 || transactionId.isEmpty) {
    return const SdkFailure(
      kind: SdkErrorKind.validation,
      message: 'Invalid XP grant',
    );
  }
  if (_transactions.contains(transactionId)) {
    return SdkSuccess(snapshot.value);
  }

  final nextTotal = _totalXpEarned + amount;
  if (nextTotal < _totalXpEarned || nextTotal > _maxSupportedCumulativeXp) {
    return const SdkFailure(
      kind: SdkErrorKind.validation,
      message: 'XP grant would overflow the supported range',
    );
  }

  final newLevel = levelForTotalXp(_curve, nextTotal);
  final leveledUpTo = [
    for (var lvl = _highestUnlockedLevel + 1; lvl <= newLevel; lvl++) lvl,
  ];

  // Snapshot for rollback: if persisting fails the grant must not be
  // remembered as done, or a retry with the same id returns success while
  // nothing was ever saved.
  final previousTotal = _totalXpEarned;
  final previousHighest = _highestUnlockedLevel;
  final previousTransactions = List<String>.of(_transactions);

  _totalXpEarned = nextTotal;
  _appendTransaction(transactionId);
  if (leveledUpTo.isNotEmpty) _highestUnlockedLevel = newLevel;
  try {
    await _persist();
  } catch (_) {
    _totalXpEarned = previousTotal;
    _highestUnlockedLevel = previousHighest;
    _transactions
      ..clear()
      ..addAll(previousTransactions);
    rethrow;
  }
  _recompute();

  for (final lvl in leveledUpTo) {
    final def = _curve[lvl - 1];
    if (def.unlockRewardLines.isNotEmpty && pipeline != null) {
      await pipeline!.grant(
        source: RewardSource.other,
        transactionId: 'level_up:$lvl',
        lines: def.unlockRewardLines,
      );
    }
    onLevelUp.value = LevelUpEvent(
      level: lvl,
      rewardLines: def.unlockRewardLines,
    );
  }

  return SdkSuccess(snapshot.value);
});