From f560b9d4c8c271d9023dc8b25257879c74880db3 Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 13:42:55 +0900 Subject: [PATCH] feat(ads): pure-Dart interstitial frequency policy with tests --- lib/services/ad_frequency_policy.dart | 47 +++++++++++++ test/services/ad_frequency_policy_test.dart | 75 +++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 lib/services/ad_frequency_policy.dart create mode 100644 test/services/ad_frequency_policy_test.dart diff --git a/lib/services/ad_frequency_policy.dart b/lib/services/ad_frequency_policy.dart new file mode 100644 index 0000000..2bbcdb6 --- /dev/null +++ b/lib/services/ad_frequency_policy.dart @@ -0,0 +1,47 @@ +/// Pure-Dart interstitial gate. No plugin imports so the monetization rules +/// are unit-tested headlessly. The service layer feeds it lifecycle events +/// and asks [canShowInterstitial] before every stage-end interstitial. +class AdFrequencyPolicy { + AdFrequencyPolicy({ + this.minStagesBetween = 3, + this.minInterval = const Duration(seconds: 90), + this.firstStagesProtected = 5, + }); + + final int minStagesBetween; + final Duration minInterval; + final int firstStagesProtected; + + int _totalStagesCompleted = 0; + int _stagesSinceLastInterstitial = 0; + bool _rewardedShownThisRound = false; + DateTime? _lastInterstitialAt; + + /// A new stage attempt began (fresh round). Clears the per-round rewarded + /// flag so last round's rewarded does not block this round's interstitial. + void onRoundStart() => _rewardedShownThisRound = false; + + /// A stage finished (won or lost). Drives both counters. + void onStageCompleted() { + _totalStagesCompleted++; + _stagesSinceLastInterstitial++; + } + + /// The player watched a rewarded ad in the current round. + void onRewardedShown() => _rewardedShownThisRound = true; + + /// An interstitial was actually shown. Resets the spacing counters. + void onInterstitialShown(DateTime now) { + _stagesSinceLastInterstitial = 0; + _lastInterstitialAt = now; + } + + bool canShowInterstitial(DateTime now) { + if (_totalStagesCompleted <= firstStagesProtected) return false; + if (_rewardedShownThisRound) return false; + if (_stagesSinceLastInterstitial < minStagesBetween) return false; + final last = _lastInterstitialAt; + if (last != null && now.difference(last) < minInterval) return false; + return true; + } +} diff --git a/test/services/ad_frequency_policy_test.dart b/test/services/ad_frequency_policy_test.dart new file mode 100644 index 0000000..98a3e6a --- /dev/null +++ b/test/services/ad_frequency_policy_test.dart @@ -0,0 +1,75 @@ +import 'package:block_seasons/services/ad_frequency_policy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final t0 = DateTime(2026, 1, 1, 12, 0, 0); + + AdFrequencyPolicy primed() { + // Past the 5-stage protection and the 3-stage gap, last ad long ago. + final p = AdFrequencyPolicy(); + for (var i = 0; i < 6; i++) { + p.onRoundStart(); + p.onStageCompleted(); + } + return p; + } + + test('first 5 stages are protected from interstitials', () { + final p = AdFrequencyPolicy(); + for (var i = 0; i < 5; i++) { + p.onRoundStart(); + p.onStageCompleted(); + expect(p.canShowInterstitial(t0), isFalse, reason: 'stage ${i + 1}'); + } + // 6th completed stage clears protection. + p.onRoundStart(); + p.onStageCompleted(); + expect(p.canShowInterstitial(t0), isTrue); + }); + + test('needs >=3 stages since the last interstitial', () { + final p = primed(); + expect(p.canShowInterstitial(t0), isTrue); + p.onInterstitialShown(t0); + final later = t0.add(const Duration(seconds: 120)); + // Only 2 stages since: still blocked even though time elapsed. + p.onRoundStart(); + p.onStageCompleted(); + p.onRoundStart(); + p.onStageCompleted(); + expect(p.canShowInterstitial(later), isFalse); + // 3rd stage opens the gate. + p.onRoundStart(); + p.onStageCompleted(); + expect(p.canShowInterstitial(later), isTrue); + }); + + test('needs >=90s since the last interstitial', () { + final p = primed(); + p.onInterstitialShown(t0); + for (var i = 0; i < 3; i++) { + p.onRoundStart(); + p.onStageCompleted(); + } + expect(p.canShowInterstitial(t0.add(const Duration(seconds: 89))), isFalse); + expect(p.canShowInterstitial(t0.add(const Duration(seconds: 90))), isTrue); + }); + + test('a rewarded watched this round blocks the interstitial', () { + final p = primed(); + expect(p.canShowInterstitial(t0), isTrue); + p.onRewardedShown(); + expect(p.canShowInterstitial(t0), isFalse); + // Next round clears it. + p.onRoundStart(); + p.onStageCompleted(); + expect(p.canShowInterstitial(t0), isTrue); + }); + + test('showing an interstitial resets the stage counter', () { + final p = primed(); + p.onInterstitialShown(t0); + final later = t0.add(const Duration(seconds: 200)); + expect(p.canShowInterstitial(later), isFalse); // 0 stages since + }); +}