347 lines
9.4 KiB
Dart
347 lines
9.4 KiB
Dart
import 'dart:io';
|
|
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<int> 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<int> 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<int>? _pool;
|
|
Future<void>? _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 (Platform.environment.containsKey('FLUTTER_TEST')) return;
|
|
if (_pool != null || _inflight != null) return;
|
|
_inflight = () async {
|
|
try {
|
|
_pool = await _fetch(count);
|
|
} catch (_) {}
|
|
}().whenComplete(() {
|
|
_inflight = null;
|
|
});
|
|
}
|
|
|
|
Future<DiceRoll> 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<int> localDigits(int count) => _localDigits(count);
|
|
|
|
List<int> _localDigits(int count) =>
|
|
List<int>.generate(count, (_) => _secure.nextInt(10));
|
|
|
|
/// Permutation of `[min, max]` inclusive. Used for the daily word matrix.
|
|
Future<RangeShuffle> 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<int> 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<List<int>> _fetch(int count) async {
|
|
await _throttle();
|
|
if (hasApiKey) {
|
|
return _fetchJsonRpcIntegers(
|
|
n: count,
|
|
min: 0,
|
|
max: 9,
|
|
replacement: true,
|
|
);
|
|
}
|
|
return _fetchHttpIntegers(count);
|
|
}
|
|
|
|
Future<void> _throttle() async {
|
|
final last = _lastRequest;
|
|
if (last != null && minInterval > Duration.zero) {
|
|
final wait = minInterval - DateTime.now().difference(last);
|
|
if (wait > Duration.zero) {
|
|
await Future<void>.delayed(wait);
|
|
}
|
|
}
|
|
_lastRequest = DateTime.now();
|
|
}
|
|
|
|
Future<List<int>> _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<List<int>> _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<List<int>> _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<int> 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<int> 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<int> 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<int> values, int min, int max) {
|
|
final expected = max - min + 1;
|
|
if (values.length != expected) return false;
|
|
final seen = <int>{};
|
|
for (final v in values) {
|
|
if (v < min || v > max || !seen.add(v)) return false;
|
|
}
|
|
return seen.length == expected;
|
|
}
|