import 'dart:convert'; import 'dart:math'; import 'package:http/http.dart' as http; import 'settings_repository.dart'; /// Where a d10 roll came from. enum RandomSource { /// Atmospheric noise via RANDOM.ORG. randomOrg, /// OS CSPRNG. Used offline or when RANDOM.ORG is unavailable. local, } class DiceRoll { final List digits; final RandomSource source; const DiceRoll(this.digits, this.source); int get asNumber { var n = 0; for (final d in digits) { n = n * 10 + d; } return n; } } class RangeShuffle { final List order; final RandomSource source; const RangeShuffle(this.order, this.source); } /// True random for cleromancy rolls. /// /// Order of attempts: /// 1. Official JSON-RPC at api.random.org — if an API key is set /// (settings, `--dart-define=RANDOM_ORG_API_KEY=...`, or lib/secrets.dart) /// 2. Legacy HTTP integer API — no key, quota by IP /// 3. [Random.secure] — always available offline class TrueRandomService { TrueRandomService({ http.Client? client, String? apiKey, this.timeout = const Duration(milliseconds: 1200), this.minInterval = const Duration(milliseconds: 1100), }) : _client = client ?? http.Client(), _apiKeyOverride = apiKey; static final TrueRandomService instance = TrueRandomService(); static const _userAgent = 'Oracle999/1.0 (Flutter; cleromancy client)'; final http.Client _client; final String? _apiKeyOverride; final Duration timeout; final Duration minInterval; final _secure = Random.secure(); List? _pool; Future? _inflight; DateTime? _lastRequest; String get effectiveApiKey { final override = _apiKeyOverride; if (override != null) return override.trim(); return SettingsRepository().randomOrgApiKey.trim(); } bool get hasApiKey => effectiveApiKey.isNotEmpty; void invalidatePool() { _pool = null; } /// Warm the pool so the next roll does not wait on the network. void prefetch({int count = 3}) { if (_pool != null || _inflight != null) return; _inflight = () async { try { _pool = await _fetch(count); } catch (_) {} }().whenComplete(() { _inflight = null; }); } Future take(int count) async { if (_pool != null && _pool!.length >= count) { final out = _pool!.sublist(0, count); _pool = null; prefetch(count: count); return DiceRoll(out, RandomSource.randomOrg); } if (_inflight != null) { try { await _inflight!.timeout(const Duration(milliseconds: 800)); } catch (_) {} if (_pool != null && _pool!.length >= count) { final out = _pool!.sublist(0, count); _pool = null; prefetch(count: count); return DiceRoll(out, RandomSource.randomOrg); } } try { final live = await _fetch(count).timeout(timeout); prefetch(count: count); return DiceRoll(live, RandomSource.randomOrg); } catch (_) { prefetch(count: count); return DiceRoll(_localDigits(count), RandomSource.local); } } List localDigits(int count) => _localDigits(count); List _localDigits(int count) => List.generate(count, (_) => _secure.nextInt(10)); /// Permutation of `[min, max]` inclusive. Used for the daily word matrix. Future permutation({ required int min, required int max, Duration timeout = const Duration(seconds: 10), }) async { if (max < min) { throw ArgumentError.value(max, 'max', 'must be >= min'); } try { await _throttle(); final values = hasApiKey ? await _fetchJsonRpcIntegers( n: max - min + 1, min: min, max: max, replacement: false, requestTimeout: timeout, ) : await _fetchHttpSequence( min: min, max: max, requestTimeout: timeout, ); if (!isPermutation(values, min, max)) { throw const FormatException('RANDOM.ORG sequence is not a permutation'); } return RangeShuffle(values, RandomSource.randomOrg); } catch (_) { return RangeShuffle(localPermutation(min, max), RandomSource.local); } } List localPermutation(int min, int max) { final list = [for (var i = min; i <= max; i++) i]; for (var i = list.length - 1; i > 0; i--) { final n = _secure.nextInt(i + 1); final tmp = list[i]; list[i] = list[n]; list[n] = tmp; } return list; } Future> _fetch(int count) async { await _throttle(); if (hasApiKey) { return _fetchJsonRpcIntegers( n: count, min: 0, max: 9, replacement: true, ); } return _fetchHttpIntegers(count); } Future _throttle() async { final last = _lastRequest; if (last != null && minInterval > Duration.zero) { final wait = minInterval - DateTime.now().difference(last); if (wait > Duration.zero) { await Future.delayed(wait); } } _lastRequest = DateTime.now(); } Future> _fetchHttpIntegers(int count) async { final uri = Uri.https('www.random.org', '/integers/', { 'num': '$count', 'min': '0', 'max': '9', 'col': '1', 'base': '10', 'format': 'plain', 'rnd': 'new', }); final response = await _client .get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'text/plain'}) .timeout(timeout); if (response.statusCode != 200) { throw StateError('RANDOM.ORG HTTP ${response.statusCode}'); } return parseRandomOrgPlain(response.body, count); } Future> _fetchJsonRpcIntegers({ required int n, required int min, required int max, required bool replacement, Duration? requestTimeout, }) async { final response = await _client .post( Uri.parse('https://api.random.org/json-rpc/4/invoke'), headers: { 'User-Agent': _userAgent, 'Content-Type': 'application/json', }, body: jsonEncode({ 'jsonrpc': '2.0', 'method': 'generateIntegers', 'params': { 'apiKey': effectiveApiKey, 'n': n, 'min': min, 'max': max, 'replacement': replacement, }, 'id': 1, }), ) .timeout(requestTimeout ?? timeout); if (response.statusCode != 200) { throw StateError('RANDOM.ORG JSON-RPC HTTP ${response.statusCode}'); } return parseRandomOrgJsonRpc(response.body, n, min: min, max: max); } Future> _fetchHttpSequence({ required int min, required int max, Duration? requestTimeout, }) async { final uri = Uri.https('www.random.org', '/sequences/', { 'min': '$min', 'max': '$max', 'col': '1', 'format': 'plain', 'rnd': 'new', }); final response = await _client .get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'text/plain'}) .timeout(requestTimeout ?? timeout); if (response.statusCode != 200) { throw StateError('RANDOM.ORG sequence HTTP ${response.statusCode}'); } return parseRandomOrgSequence(response.body, min, max); } } List parseRandomOrgPlain(String body, int count) { final trimmed = body.trim(); if (trimmed.startsWith('Error:')) { throw FormatException(trimmed); } final nums = trimmed .split(RegExp(r'\s+')) .where((s) => s.isNotEmpty) .map(int.parse) .toList(); if (nums.length < count || nums.any((n) => n < 0 || n > 9)) { throw FormatException('bad RANDOM.ORG integers: $body'); } return nums.sublist(0, count); } List parseRandomOrgJsonRpc( String body, int count, { int min = 0, int max = 9, }) { final map = jsonDecode(body); if (map is! Map) { throw const FormatException('RANDOM.ORG JSON-RPC: expected object'); } final error = map['error']; if (error != null) { throw FormatException('RANDOM.ORG JSON-RPC error: $error'); } final result = map['result']; if (result is! Map) { throw const FormatException('RANDOM.ORG JSON-RPC: missing result'); } final random = result['random']; if (random is! Map) { throw const FormatException('RANDOM.ORG JSON-RPC: missing random'); } final data = random['data']; if (data is! List) { throw const FormatException('RANDOM.ORG JSON-RPC: missing data'); } final nums = data.map((e) => (e as num).toInt()).toList(); if (nums.length < count || nums.any((n) => n < min || n > max)) { throw FormatException('bad RANDOM.ORG JSON data: $data'); } return nums.sublist(0, count); } List parseRandomOrgSequence(String body, int min, int max) { final trimmed = body.trim(); if (trimmed.startsWith('Error:')) { throw FormatException(trimmed); } final nums = trimmed .split(RegExp(r'\s+')) .where((s) => s.isNotEmpty) .map(int.parse) .toList(); if (!isPermutation(nums, min, max)) { throw FormatException('bad RANDOM.ORG sequence: $body'); } return nums; } bool isPermutation(List values, int min, int max) { final expected = max - min + 1; if (values.length != expected) return false; final seen = {}; for (final v in values) { if (v < min || v > max || !seen.add(v)) return false; } return seen.length == expected; }