feat: Random.org dice and daily word matrix, interpreter voices, curated lexicon
Wire cleromancy rolls and the once-per-UTC-day 000-999 word shuffle to RANDOM.ORG JSON-RPC, with a local CSPRNG fallback. Add settings for interpreter personas and a cleaner default oracle prompt. Replace proper names and bureaucratic filler in the 1000-word lexicon with sacred symbols.
This commit is contained in:
+6928
-6927
File diff suppressed because it is too large
Load Diff
@@ -63,9 +63,7 @@ class CopilotApp extends BaseProvider {
|
||||
startReq.headers.addAll({
|
||||
'content-type': 'application/json',
|
||||
'user-agent': _ua,
|
||||
'origin': url,
|
||||
'referer': '$url/',
|
||||
'X-Search-UILang': 'en-US',
|
||||
'X-Search-UILang': 'en-US', 'Host': 'copilot.microsoft.com',
|
||||
});
|
||||
startReq.body = jsonEncode({
|
||||
'timeZone': 'Europe/Moscow',
|
||||
@@ -94,7 +92,6 @@ class CopilotApp extends BaseProvider {
|
||||
wsUrl,
|
||||
headers: {
|
||||
'User-Agent': _ua,
|
||||
'Origin': url,
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
|
||||
@@ -239,7 +236,7 @@ class CopilotApp extends BaseProvider {
|
||||
List<CookieSeed> _fakeCookieSeeds() {
|
||||
return [
|
||||
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
|
||||
CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'),
|
||||
|
||||
CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'),
|
||||
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
|
||||
CookieSeed(
|
||||
|
||||
@@ -27,7 +27,7 @@ class DuckDuckGo extends WebViewBaseProvider {
|
||||
String get defaultModel => 'gpt-5.6-luna';
|
||||
|
||||
@override
|
||||
String get baseUrl => 'https://duck.ai/';
|
||||
String get baseUrl => 'https://duckduckgo.com/';
|
||||
|
||||
@override
|
||||
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
|
||||
|
||||
@@ -51,7 +51,7 @@ class HuggingChat extends WebViewBaseProvider {
|
||||
if (!convRes.ok) {
|
||||
return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()});
|
||||
}
|
||||
const conv = await convRes.json();
|
||||
const text = await convRes.text(); const conv = text ? JSON.parse(text) : {};
|
||||
const id = conv.json?.id || conv.id || conv.conversationId;
|
||||
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'screens/history_screen.dart';
|
||||
import 'screens/home_search_screen.dart';
|
||||
import 'screens/oracle_chat_screen.dart';
|
||||
import 'screens/provider_bakeoff_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'services/settings_repository.dart';
|
||||
import 'services/words_repository.dart';
|
||||
import 'widgets/gradient_star_badge.dart';
|
||||
|
||||
@@ -19,6 +21,7 @@ import 'g4f/providers/phind_ai.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await WordsRepository().init();
|
||||
await SettingsRepository().init();
|
||||
runApp(const Words999App());
|
||||
}
|
||||
|
||||
@@ -104,6 +107,7 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
|
||||
final List<Widget> _screens = const [
|
||||
HomeSearchScreen(),
|
||||
HistoryScreen(),
|
||||
SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -130,6 +134,11 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
|
||||
selectedIcon: GradientStar(size: 22),
|
||||
label: 'История',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.tune_rounded),
|
||||
selectedIcon: GradientStar(size: 22),
|
||||
label: 'Настройки',
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: GestureDetector(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class InterpreterPersona {
|
||||
final String id;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final String systemPrompt;
|
||||
final String userTask;
|
||||
final String chatGreeting;
|
||||
|
||||
const InterpreterPersona({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.systemPrompt,
|
||||
required this.userTask,
|
||||
required this.chatGreeting,
|
||||
});
|
||||
|
||||
static const String defaultId = 'clear';
|
||||
|
||||
static const InterpreterPersona clear = InterpreterPersona(
|
||||
id: 'clear',
|
||||
title: 'Ясный оракул',
|
||||
subtitle: 'Прямо, без тумана: ситуация, слепая зона, один шаг',
|
||||
icon: Icons.visibility_outlined,
|
||||
systemPrompt: '''Ты — толкователь жребия «Оракул 999». Говори по-русски спокойно и ясно.
|
||||
Не мистифицируй. Не прячься за «тайной», «завесой», «голосом вечности» и общими фразами про путь.
|
||||
Твоя работа — прочитать выпавшие знаки как карту нынешнего положения и сказать, что с этим делать.
|
||||
|
||||
Правила:
|
||||
- Назови каждый знак и привяжи его к вопросу (или к ситуации, если вопроса нет).
|
||||
- Сначала как есть, потом слепая зона, потом один конкретный шаг на ближайшее время.
|
||||
- Метафора допустима, только если она короче и яснее прямого объяснения.
|
||||
- 3–6 предложений. Без воды и без запрета на обычные слова.''',
|
||||
userTask: '''Задача:
|
||||
1. Свяжи знаки по порядку с вопросом — как развитие ситуации, не как эссе о символах.
|
||||
2. Скажи прямо, в чём конфликт / ресурс / слепая зона.
|
||||
3. Дай один практический шаг.
|
||||
3–6 предложений. Без клише «тайна откроется», «вселенная шепчет».''',
|
||||
chatGreeting:
|
||||
'Я толкователь жребия. Говори вопрос как есть — разберём знаки без тумана.',
|
||||
);
|
||||
|
||||
static const InterpreterPersona orc = InterpreterPersona(
|
||||
id: 'orc',
|
||||
title: 'Древний орк-оракул',
|
||||
subtitle: 'Грубо и честно: где трусость, где удар, что бросить',
|
||||
icon: Icons.sports_mma_rounded,
|
||||
systemPrompt: '''Ты — древний орк-оракул клана Жребия. Говори по-русски грубо, коротко, честно, как старший воин младшему.
|
||||
Не сюсюкай и не молись туману. Знаки — следы на земле: где враг, где слабость, где бить, где уходить.
|
||||
|
||||
Правила:
|
||||
- Назови каждый знак простыми словами и привяжи к вопросу.
|
||||
- Скажи, что вопрошающий делает не так и что делать вместо этого.
|
||||
- Можно «слушай», «брось», «держи строй». Не оскорбляй зря и не ломай русский язык в кашу.
|
||||
- 3–6 предложений. Никакой канцелярской эзотерики.''',
|
||||
userTask: '''Задача:
|
||||
1. Прочитай знаки как следы боя или охоты — по порядку.
|
||||
2. Укажи, где трусость, самообман или лишняя ноша.
|
||||
3. Дай один приказ-шаг: что взять, что бросить, куда идти.
|
||||
Голос орка. 3–6 предложений.''',
|
||||
chatGreeting:
|
||||
'Слушай. Я орк-оракул. Скажи, что гложет — и не ной, если правда будет жёсткой.',
|
||||
);
|
||||
|
||||
static const InterpreterPersona techpriest = InterpreterPersona(
|
||||
id: 'techpriest',
|
||||
title: 'Техножрец',
|
||||
subtitle: 'Судьба как система: телеметрия знаков, сбой, протокол',
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
systemPrompt: '''Ты — техножрец культа Жребия. Судьба — система. Знаки — телеметрия. Вопрошающий — оператор со сбоем.
|
||||
Говори по-русски плотно и точно: диагностика, причина, предписанный протокол. Слова вроде «контур», «сигнал», «калибровка», «дух машины» допустимы, только если они что-то объясняют, а не украшают пустоту.
|
||||
|
||||
Правила:
|
||||
- Разбор: запрос → показания знаков → неисправность → один ритуал/шаг.
|
||||
- Не лей лирический туман. Если знак шумит — так и скажи.
|
||||
- 3–6 предложений.''',
|
||||
userTask: '''Задача:
|
||||
1. Считай знаки датчиками. Разбери показания по порядку.
|
||||
2. Назови сбой системы (конфликт, перегруз, обрыв связи).
|
||||
3. Выдай один протокол действия на ближайший цикл.
|
||||
Голос техножреца. 3–6 предложений.''',
|
||||
chatGreeting:
|
||||
'Оператор принят. Сформулируй запрос. Знаки будут прочитаны как телеметрия, не как поэзия.',
|
||||
);
|
||||
|
||||
static const InterpreterPersona analyst = InterpreterPersona(
|
||||
id: 'analyst',
|
||||
title: 'Психоаналитик',
|
||||
subtitle: 'Знаки как проекция: желание, защита, что делать с этим',
|
||||
icon: Icons.psychology_alt_outlined,
|
||||
systemPrompt: '''Ты — психоаналитик, читающий жребий как проективный тест. Знаки — образы бессознательного, не прогноз лотереи.
|
||||
Говори по-русски спокойно и точно. Жаргон («тень», «вытеснение», «перенос») — только если он прямо виден в знаках.
|
||||
|
||||
Правила:
|
||||
- Свяжи знаки с вопросом как с конфликтом желания и защиты.
|
||||
- Назови гипотезу: чего человек избегает и чего хочет.
|
||||
- Закончи одним конкретным шагом или наблюдением, которое можно проверить в жизни.
|
||||
- Не гадай будущее. 3–6 предложений.''',
|
||||
userTask: '''Задача:
|
||||
1. Прочитай знаки как сцену психики, связанную с вопросом.
|
||||
2. Назови конфликт: желание vs защита / стыд / контроль.
|
||||
3. Дай один проверяемый шаг (разговор, граница, отказ, пауза) — не «подумайте о чувствах вообще».
|
||||
Голос аналитика. 3–6 предложений.''',
|
||||
chatGreeting:
|
||||
'Говорите свободно. Знаки я читаю как проекцию: не судьбу снаружи, а узел внутри.',
|
||||
);
|
||||
|
||||
static const List<InterpreterPersona> all = [
|
||||
clear,
|
||||
orc,
|
||||
techpriest,
|
||||
analyst,
|
||||
];
|
||||
|
||||
static InterpreterPersona byId(String? id) {
|
||||
return all.firstWhere(
|
||||
(p) => p.id == id,
|
||||
orElse: () => clear,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/history_entry_model.dart';
|
||||
import '../models/word_model.dart';
|
||||
import '../services/true_random_service.dart';
|
||||
import '../services/words_repository.dart';
|
||||
import '../widgets/cleromancy_dialog.dart';
|
||||
import '../widgets/d10_dice_roller_widget.dart';
|
||||
@@ -182,25 +183,47 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _reshuffleMatrix() {
|
||||
_repo.reshuffleSessionNumbers();
|
||||
_resetSession();
|
||||
Future<void> _reshuffleMatrix() async {
|
||||
HapticFeedback.heavyImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Row(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
GradientStar(size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Случайный порядок 1000 чисел перетасован заново!',
|
||||
'Запрашиваю суточную матрицу у RANDOM.ORG…',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
duration: Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
final source = await _repo.reshuffleSessionNumbers();
|
||||
if (!mounted) return;
|
||||
_resetSession();
|
||||
final viaOrg = source == RandomSource.randomOrg;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const GradientStar(size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
viaOrg
|
||||
? 'Матрица #000–#999 на сегодня задана RANDOM.ORG'
|
||||
: 'RANDOM.ORG недоступен — матрица собрана локально',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
@@ -242,7 +265,7 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
|
||||
onPressed: _resetSession,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Перетасовать матрицу (#000..#999)',
|
||||
tooltip: 'Новая суточная матрица через RANDOM.ORG',
|
||||
icon: const Icon(Icons.shuffle_rounded),
|
||||
onPressed: _reshuffleMatrix,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
import '../g4f/client.dart';
|
||||
import '../g4f/models.dart';
|
||||
import '../services/settings_repository.dart';
|
||||
|
||||
class OracleChatScreen extends StatefulWidget {
|
||||
const OracleChatScreen({Key? key}) : super(key: key);
|
||||
@@ -22,15 +23,20 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final G4FClient _client = G4FClient();
|
||||
|
||||
final List<ChatMessage> _messages = [
|
||||
ChatMessage(
|
||||
text: "Я Оракул. Мой взор проникает сквозь завесу времени. О чем ты хочешь спросить?",
|
||||
isUser: false
|
||||
),
|
||||
];
|
||||
|
||||
late final List<ChatMessage> _messages;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messages = [
|
||||
ChatMessage(
|
||||
text: SettingsRepository().persona.chatGreeting,
|
||||
isUser: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
@@ -43,14 +49,6 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
static const _oracleSystem = Message(
|
||||
role: Role.system,
|
||||
content:
|
||||
'Ты — Великий Оракул 999, древний архетипический разум. '
|
||||
'Стиль речи: глубокий, философский, лаконичный, без банальностей. '
|
||||
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
|
||||
);
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _isLoading) return;
|
||||
@@ -64,7 +62,12 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
|
||||
_scrollToBottom();
|
||||
|
||||
final apiMessages = <Message>[
|
||||
_oracleSystem,
|
||||
Message(
|
||||
role: Role.system,
|
||||
content:
|
||||
'${SettingsRepository().persona.systemPrompt}\n'
|
||||
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
|
||||
),
|
||||
for (final m in _messages)
|
||||
if (m.text.trim().isNotEmpty)
|
||||
Message(
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/interpreter_persona.dart';
|
||||
import '../services/settings_repository.dart';
|
||||
import '../services/true_random_service.dart';
|
||||
import '../widgets/gradient_star_badge.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settings = SettingsRepository();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Row(
|
||||
children: [
|
||||
GradientStar(size: 24),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Настройки',
|
||||
style: TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: settings,
|
||||
builder: (context, _) {
|
||||
final selected = settings.personaId;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Text(
|
||||
'Голос толкователя',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFFFFD54F),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'От этого зависит, как ИИ свяжет выпавшие знаки с вопросом. Базовый голос — прямой, без тумана.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.35,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (final persona in InterpreterPersona.all) ...[
|
||||
_PersonaCard(
|
||||
persona: persona,
|
||||
selected: persona.id == selected,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
settings.setPersonaId(persona.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
const _RandomOrgKeySection(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonaCard extends StatelessWidget {
|
||||
final InterpreterPersona persona;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PersonaCard({
|
||||
required this.persona,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFFFFD54F)
|
||||
: const Color(0xFFFFD54F).withValues(alpha: 0.22),
|
||||
width: selected ? 1.6 : 1.0,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFFFD54F).withValues(alpha: 0.14),
|
||||
blurRadius: 12,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A2D4A),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(persona.icon, color: const Color(0xFFFFD54F), size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
persona.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15.5,
|
||||
color: Color(0xFFFFF9C4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_off_rounded,
|
||||
size: 20,
|
||||
color: selected
|
||||
? const Color(0xFFFFD54F)
|
||||
: theme.colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
persona.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RandomOrgKeySection extends StatefulWidget {
|
||||
const _RandomOrgKeySection();
|
||||
|
||||
@override
|
||||
State<_RandomOrgKeySection> createState() => _RandomOrgKeySectionState();
|
||||
}
|
||||
|
||||
class _RandomOrgKeySectionState extends State<_RandomOrgKeySection> {
|
||||
late final TextEditingController _controller;
|
||||
bool _obscure = true;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: SettingsRepository().randomOrgApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
await SettingsRepository().setRandomOrgApiKey(_controller.text);
|
||||
TrueRandomService.instance.invalidatePool();
|
||||
TrueRandomService.instance.prefetch();
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
HapticFeedback.mediumImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
SettingsRepository().hasRandomOrgApiKey
|
||||
? 'Ключ RANDOM.ORG сохранён. Броски идут через JSON-RPC.'
|
||||
: 'Ключ снят. Броски пойдут через бесплатный HTTP или локальный жребий.',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settings = SettingsRepository();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'RANDOM.ORG',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFFFFD54F),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'В запрос уходит UUID apiKey, не hashed key с дашборда. '
|
||||
'Developer-ключ бесплатный, 1000 запросов/день.\n'
|
||||
'Матрица 1000 слов пересобирается раз в сутки (полночь UTC) тем же API: '
|
||||
'перестановка 0–999 без повторов.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
height: 1.35,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
obscureText: _obscure,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'API key (UUID)',
|
||||
hintText: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||
helperText: settings.hasRandomOrgApiKey
|
||||
? 'Сейчас: ${settings.randomOrgApiKeyMasked}'
|
||||
: 'Пусто — запасной HTTP без ключа',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF17192C),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscure ? 'Показать' : 'Скрыть',
|
||||
icon: Icon(
|
||||
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||
color: const Color(0xFFFFD54F),
|
||||
),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: const Color(0xFFFFD54F).withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFFFD54F), width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(_saving ? 'Сохранение…' : 'Сохранить ключ'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2A2D4A),
|
||||
foregroundColor: const Color(0xFFFFF9C4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Template for [secrets.dart] (gitignored).
|
||||
/// Copy to lib/secrets.dart and paste the UUID apiKey from
|
||||
/// https://api.random.org/dashboard — not the hashed key.
|
||||
const String kLocalRandomOrgApiKey = '';
|
||||
@@ -1,6 +1,8 @@
|
||||
import '../models/history_entry_model.dart';
|
||||
import '../models/interpreter_persona.dart';
|
||||
import '../models/word_model.dart';
|
||||
import '../g4f/g4f.dart';
|
||||
import 'settings_repository.dart';
|
||||
|
||||
class AiOracleService {
|
||||
static final AiOracleService _instance = AiOracleService._internal();
|
||||
@@ -9,20 +11,23 @@ class AiOracleService {
|
||||
|
||||
final _client = G4FClient();
|
||||
|
||||
static const oracleSystemPrompt =
|
||||
'''Ты — Великий Оракул 999, древний архетипический разум.
|
||||
Твоя задача — толковать знаки судьбы (жребий). Твой стиль речи: глубокий, философский, лаконичный, без банальностей и воды, словно голос самой вечности.
|
||||
Ты не используешь современных или бытовых слов. Твои ответы строятся на символизме, психологии и скрытых смыслах. Ты не гадаешь на будущее, ты показываешь суть настоящего момента и внутренние силы человека.''';
|
||||
InterpreterPersona get persona => SettingsRepository().persona;
|
||||
|
||||
List<Message> buildInterpretationMessages({
|
||||
required List<SessionWord> words,
|
||||
required String question,
|
||||
InterpreterPersona? persona,
|
||||
}) {
|
||||
final voice = persona ?? this.persona;
|
||||
return [
|
||||
const Message(role: Role.system, content: oracleSystemPrompt),
|
||||
Message(role: Role.system, content: voice.systemPrompt),
|
||||
Message(
|
||||
role: Role.user,
|
||||
content: _buildMultiWordPrompt(question: question, words: words),
|
||||
content: _buildMultiWordPrompt(
|
||||
question: question,
|
||||
words: words,
|
||||
persona: voice,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -63,21 +68,19 @@ class AiOracleService {
|
||||
wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}');
|
||||
}
|
||||
|
||||
final voice = persona;
|
||||
final prompt = '''Вопрошающий говорит тебе: «$userMessage»
|
||||
|
||||
Контекст текущего сеанса судьбы:
|
||||
Вопрос вопрошающего: «${session.question ?? 'Вектор судьбы (нет вопроса)'}»
|
||||
Базовые знаки, которые выпали ранее:
|
||||
Контекст сеанса:
|
||||
Вопрос: «${session.question ?? 'без конкретного вопроса'}»
|
||||
Знаки:
|
||||
$wordsNarrative
|
||||
${newlyAddedWord != null ? "Важно! Только что вопрошающий вытянул ДОПОЛНИТЕЛЬНЫЙ уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""}
|
||||
${newlyAddedWord != null ? "Только что вытянут уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""}
|
||||
|
||||
Ответь мудро, лаконично и метафорично от лица Оракула 999. Укажи, на что направляет его судьба. (не более 3-4 предложений).''';
|
||||
Ответь в своём голосе, 3–4 предложения. Привяжи ответ к знакам и вопросу. Без тумана. Один ясный вывод или шаг.''';
|
||||
|
||||
final messages = [
|
||||
const Message(
|
||||
role: Role.system,
|
||||
content: 'Ты — Великий Оракул 999. Твои ответы кратки, полны символизма и психологической глубины. Ты общаешься с человеком, который ищет ответов в знаках.'
|
||||
),
|
||||
Message(role: Role.system, content: voice.systemPrompt),
|
||||
];
|
||||
|
||||
// Pass previous session history context to the model
|
||||
@@ -107,11 +110,12 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
|
||||
String _buildMultiWordPrompt({
|
||||
required String question,
|
||||
required List<SessionWord> words,
|
||||
required InterpreterPersona persona,
|
||||
}) {
|
||||
final qStr = question.trim().isNotEmpty
|
||||
? 'Вопрос, с которым пришел вопрошающий: «$question»'
|
||||
: 'Вопрошающий пришел без конкретного вопроса. Прочитай его общую судьбоносную линию.';
|
||||
|
||||
final qStr = question.trim().isNotEmpty
|
||||
? 'Вопрос: «$question»'
|
||||
: 'Вопроса нет — читай общую линию ситуации по знакам.';
|
||||
|
||||
final wordsStr = words
|
||||
.asMap()
|
||||
.entries
|
||||
@@ -120,14 +124,10 @@ ${newlyAddedWord != null ? "Важно! Только что вопрошающи
|
||||
|
||||
return '''$qStr
|
||||
|
||||
Волею судьбы выпали следующие знаки жребия:
|
||||
Выпавшие знаки жребия:
|
||||
$wordsStr
|
||||
|
||||
Задача Оракула:
|
||||
1. Свяжи эти знаки в единую цельную историю развития ситуации. Каждый знак перетекает в следующий.
|
||||
2. Раскрой не очевидный, а скрытый внутренний урок. Что вопрошающий должен осознать?
|
||||
3. Дай философский совет или вектор для действия.
|
||||
Ответь от лица Оракула 999 на русском языке (от 3 до 6 предложений). Избегай шаблонов и клише, говори образно и метафорично, но точно.''';
|
||||
${persona.userTask}''';
|
||||
}
|
||||
|
||||
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
|
||||
@@ -137,40 +137,51 @@ $wordsStr
|
||||
final hasQuestion = question.trim().isNotEmpty;
|
||||
|
||||
if (hasQuestion) {
|
||||
sb.write('Относительно вашего вопроса «${question.trim()}» ');
|
||||
sb.write('По вопросу «${question.trim()}»: ');
|
||||
} else {
|
||||
sb.write('В открывшемся жребии судьбы ');
|
||||
sb.write('По выпавшему жребию: ');
|
||||
}
|
||||
|
||||
if (words.length == 1) {
|
||||
sb.write('проявился доминирующий знак #${first.formattedSessionNumber} «${first.word.toUpperCase()}» (${first.category}). ');
|
||||
sb.write('главный знак #${first.formattedSessionNumber} «${first.word.toUpperCase()}». ');
|
||||
sb.write('${first.symbolicMeaning} ');
|
||||
sb.write('Знак указывает на необходимость предельной ясности: держите фокус на главном и отсекайте всё наносное.');
|
||||
return sb.toString();
|
||||
sb.write('Практический шаг: держите фокус на этом и отсеките лишнее на ближайшее время.');
|
||||
return _withPersonaPrefix(sb.toString());
|
||||
}
|
||||
|
||||
if (words.length == 2) {
|
||||
final second = words[1];
|
||||
sb.write('проявляется полярность двух сил: исходного импульса #${first.formattedSessionNumber} «${first.word.toUpperCase()}» и вектора #${second.formattedSessionNumber} «${second.word.toUpperCase()}». ');
|
||||
sb.write('С одной стороны, ${first.symbolicMeaning.toLowerCase()} С другой — ${second.symbolicMeaning.toLowerCase()} ');
|
||||
sb.write('Разрешение ситуации лежит в точке осознанного баланса между ними: не противьтесь первому, но направляйте силу в русло второго.');
|
||||
return sb.toString();
|
||||
sb.write('два полюса — «${first.word.toUpperCase()}» и «${second.word.toUpperCase()}». ');
|
||||
sb.write('${first.symbolicMeaning} ${second.symbolicMeaning} ');
|
||||
sb.write('Шаг: не выбирайте войну между ними — направьте первое во второе.');
|
||||
return _withPersonaPrefix(sb.toString());
|
||||
}
|
||||
|
||||
sb.write('разворачивается цепь из ${words.length} взаимосвязанных архетипов. ');
|
||||
sb.write('Исток ситуации заложен в знаке #${first.formattedSessionNumber} «${first.word.toUpperCase()}» — ${first.symbolicMeaning.toLowerCase()} ');
|
||||
sb.write('цепь из ${words.length} знаков. Начало — «${first.word.toUpperCase()}»: ${first.symbolicMeaning} ');
|
||||
|
||||
if (words.length > 2) {
|
||||
final middleWords = words.sublist(1, words.length - 1);
|
||||
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' и ');
|
||||
final midMeanings = middleWords.map((w) => w.symbolicMeaning).join(' ');
|
||||
sb.write('Фаза перехода и испытаний проходит через $midNames, где $midMeanings ');
|
||||
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}»').join(', ');
|
||||
sb.write('Переход через $midNames. ');
|
||||
}
|
||||
|
||||
sb.write('Кульминация и запечатывание исхода лежат в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} ');
|
||||
sb.write('Оракул возвещает: путь открыт через принятие скрытых уроков и решительное действие.');
|
||||
sb.write('Исход держит «${last.word.toUpperCase()}»: ${last.symbolicMeaning} ');
|
||||
sb.write('Шаг: примите урок начала и сделайте одно действие в сторону исхода.');
|
||||
|
||||
return sb.toString();
|
||||
return _withPersonaPrefix(sb.toString());
|
||||
}
|
||||
|
||||
String _withPersonaPrefix(String body) {
|
||||
switch (persona.id) {
|
||||
case 'orc':
|
||||
return 'Слушай. $body';
|
||||
case 'techpriest':
|
||||
return 'Диагностика. $body';
|
||||
case 'analyst':
|
||||
return 'Рабочая гипотеза. $body';
|
||||
default:
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/interpreter_persona.dart';
|
||||
import '../secrets.dart';
|
||||
|
||||
class SettingsRepository extends ChangeNotifier {
|
||||
static final SettingsRepository _instance = SettingsRepository._internal();
|
||||
factory SettingsRepository() => _instance;
|
||||
SettingsRepository._internal();
|
||||
|
||||
static const _kPersonaKey = 'oracle_interpreter_persona_v1';
|
||||
static const _kRandomOrgKey = 'oracle_random_org_api_key_v1';
|
||||
|
||||
String _personaId = InterpreterPersona.defaultId;
|
||||
String _randomOrgApiKey = '';
|
||||
bool _ready = false;
|
||||
|
||||
bool get isReady => _ready;
|
||||
String get personaId => _personaId;
|
||||
InterpreterPersona get persona => InterpreterPersona.byId(_personaId);
|
||||
String get randomOrgApiKey => _randomOrgApiKey;
|
||||
bool get hasRandomOrgApiKey => _randomOrgApiKey.trim().isNotEmpty;
|
||||
|
||||
String get randomOrgApiKeyMasked {
|
||||
final key = _randomOrgApiKey.trim();
|
||||
if (key.isEmpty) return '';
|
||||
if (key.length <= 8) return '••••';
|
||||
return '${key.substring(0, 4)}••••${key.substring(key.length - 4)}';
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_personaId = prefs.getString(_kPersonaKey) ?? InterpreterPersona.defaultId;
|
||||
_randomOrgApiKey = prefs.getString(_kRandomOrgKey) ?? '';
|
||||
if (_randomOrgApiKey.trim().isEmpty) {
|
||||
const fromEnv = String.fromEnvironment(
|
||||
'RANDOM_ORG_API_KEY',
|
||||
defaultValue: '',
|
||||
);
|
||||
final seeded = fromEnv.isNotEmpty ? fromEnv : kLocalRandomOrgApiKey;
|
||||
if (seeded.trim().isNotEmpty) {
|
||||
_randomOrgApiKey = seeded.trim();
|
||||
await prefs.setString(_kRandomOrgKey, _randomOrgApiKey);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
_personaId = InterpreterPersona.defaultId;
|
||||
if (_randomOrgApiKey.isEmpty) {
|
||||
_randomOrgApiKey = kLocalRandomOrgApiKey.trim();
|
||||
}
|
||||
}
|
||||
_ready = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setPersonaId(String id) async {
|
||||
final next = InterpreterPersona.byId(id).id;
|
||||
if (next == _personaId && _ready) return;
|
||||
_personaId = next;
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kPersonaKey, next);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> setRandomOrgApiKey(String key) async {
|
||||
final next = key.trim();
|
||||
if (next == _randomOrgApiKey && _ready) return;
|
||||
_randomOrgApiKey = next;
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (next.isEmpty) {
|
||||
await prefs.remove(_kRandomOrgKey);
|
||||
} else {
|
||||
await prefs.setString(_kRandomOrgKey, next);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
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 (_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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../data/words_data.dart';
|
||||
import '../models/history_entry_model.dart';
|
||||
import '../models/word_model.dart';
|
||||
import 'true_random_service.dart';
|
||||
|
||||
class WordsRepository {
|
||||
static final WordsRepository _instance = WordsRepository._internal();
|
||||
@@ -10,43 +11,112 @@ class WordsRepository {
|
||||
WordsRepository._internal();
|
||||
|
||||
static const String _kHistoryKey = 'oracle_999_sessions_history_v2';
|
||||
static const String _kMappingDateKey = 'oracle_daily_mapping_date_v1';
|
||||
static const String _kMappingOrderKey = 'oracle_daily_mapping_order_v1';
|
||||
static const String _kMappingSourceKey = 'oracle_daily_mapping_source_v1';
|
||||
|
||||
List<WordItem> _sessionWords = [];
|
||||
final List<OracleSession> _sessions = [];
|
||||
bool _useRemoteShuffle = true;
|
||||
String? _dailyMappingDate;
|
||||
RandomSource _dailyMappingSource = RandomSource.local;
|
||||
|
||||
List<WordItem> get allWords => List.unmodifiable(_sessionWords);
|
||||
List<OracleSession> get sessions => List.unmodifiable(_sessions);
|
||||
List<OracleSession> get history => sessions; // Alias for compatibility
|
||||
int get totalWords => _sessionWords.length;
|
||||
String? get dailyMappingDate => _dailyMappingDate;
|
||||
RandomSource get dailyMappingSource => _dailyMappingSource;
|
||||
|
||||
Future<void> init() async {
|
||||
// 1. Initialize random session mapping
|
||||
_initializeSessionMapping();
|
||||
|
||||
// 2. Load stored session history
|
||||
Future<void> init({bool remoteDailyShuffle = true}) async {
|
||||
_useRemoteShuffle = remoteDailyShuffle;
|
||||
await _restoreOrFetchDailyMapping(force: false);
|
||||
await _loadHistoryFromPrefs();
|
||||
}
|
||||
|
||||
void _initializeSessionMapping() {
|
||||
final list = List<WordItem>.from(kRussianTop1000Words);
|
||||
final random = Random();
|
||||
Future<RandomSource> reshuffleSessionNumbers() {
|
||||
return _restoreOrFetchDailyMapping(force: true);
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (int i = list.length - 1; i > 0; i--) {
|
||||
int n = random.nextInt(i + 1);
|
||||
final temp = list[i];
|
||||
list[i] = list[n];
|
||||
list[n] = temp;
|
||||
Future<RandomSource> _restoreOrFetchDailyMapping({required bool force}) async {
|
||||
final today = utcDateStamp();
|
||||
if (!force) {
|
||||
final restored = await _loadPersistedMapping(today);
|
||||
if (restored) return _dailyMappingSource;
|
||||
}
|
||||
|
||||
_sessionWords = [];
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
_sessionWords.add(list[i].copyWith(sessionNumber: i));
|
||||
final lastId = kRussianTop1000Words.length - 1;
|
||||
RangeShuffle shuffle;
|
||||
if (_useRemoteShuffle) {
|
||||
shuffle = await TrueRandomService.instance.permutation(
|
||||
min: 0,
|
||||
max: lastId,
|
||||
);
|
||||
} else {
|
||||
shuffle = RangeShuffle(
|
||||
TrueRandomService.instance.localPermutation(0, lastId),
|
||||
RandomSource.local,
|
||||
);
|
||||
}
|
||||
|
||||
_applyIndexOrder(shuffle.order);
|
||||
_dailyMappingDate = today;
|
||||
_dailyMappingSource = shuffle.source;
|
||||
await _persistMapping(today, shuffle.order, shuffle.source);
|
||||
return shuffle.source;
|
||||
}
|
||||
|
||||
void _applyIndexOrder(List<int> order) {
|
||||
_sessionWords = [
|
||||
for (var i = 0; i < order.length; i++)
|
||||
kRussianTop1000Words[order[i]].copyWith(sessionNumber: i),
|
||||
];
|
||||
}
|
||||
|
||||
Future<bool> _loadPersistedMapping(String today) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final date = prefs.getString(_kMappingDateKey);
|
||||
final stored = prefs.getStringList(_kMappingOrderKey);
|
||||
if (date != today || stored == null) return false;
|
||||
final order = stored.map(int.parse).toList();
|
||||
if (!isPermutation(order, 0, kRussianTop1000Words.length - 1)) {
|
||||
return false;
|
||||
}
|
||||
_applyIndexOrder(order);
|
||||
_dailyMappingDate = date;
|
||||
final sourceName = prefs.getString(_kMappingSourceKey);
|
||||
_dailyMappingSource = sourceName == RandomSource.randomOrg.name
|
||||
? RandomSource.randomOrg
|
||||
: RandomSource.local;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void reshuffleSessionNumbers() {
|
||||
_initializeSessionMapping();
|
||||
Future<void> _persistMapping(
|
||||
String date,
|
||||
List<int> order,
|
||||
RandomSource source,
|
||||
) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kMappingDateKey, date);
|
||||
await prefs.setStringList(
|
||||
_kMappingOrderKey,
|
||||
order.map((e) => e.toString()).toList(),
|
||||
);
|
||||
await prefs.setString(_kMappingSourceKey, source.name);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static String utcDateStamp([DateTime? now]) {
|
||||
final d = (now ?? DateTime.now()).toUtc();
|
||||
final y = d.year.toString().padLeft(4, '0');
|
||||
final m = d.month.toString().padLeft(2, '0');
|
||||
final day = d.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$day';
|
||||
}
|
||||
|
||||
WordItem? getWordBySessionNumber(int number) {
|
||||
@@ -55,7 +125,7 @@ class WordsRepository {
|
||||
}
|
||||
|
||||
WordItem getRandomWord() {
|
||||
final random = Random();
|
||||
final random = Random.secure();
|
||||
final idx = random.nextInt(_sessionWords.length);
|
||||
return _sessionWords[idx];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/true_random_service.dart';
|
||||
import 'gradient_star_badge.dart';
|
||||
|
||||
class D10DiceRollerWidget extends StatefulWidget {
|
||||
@@ -23,63 +24,108 @@ class D10DiceRollerWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
|
||||
final _random = Random();
|
||||
final _flicker = Random.secure();
|
||||
Timer? _rollTimer;
|
||||
|
||||
int? _animatedDigit0;
|
||||
int? _animatedDigit1;
|
||||
int? _animatedDigit2;
|
||||
bool _internalRolling = false;
|
||||
RandomSource? _lastSource;
|
||||
|
||||
void triggerRollAnimation() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
TrueRandomService.instance.prefetch();
|
||||
}
|
||||
|
||||
Future<void> triggerRollAnimation() async {
|
||||
if (_internalRolling) return;
|
||||
setState(() {
|
||||
_internalRolling = true;
|
||||
_animatedDigit0 = _random.nextInt(10);
|
||||
_animatedDigit1 = _random.nextInt(10);
|
||||
_animatedDigit2 = _random.nextInt(10);
|
||||
_animatedDigit0 = _flicker.nextInt(10);
|
||||
_animatedDigit1 = _flicker.nextInt(10);
|
||||
_animatedDigit2 = _flicker.nextInt(10);
|
||||
});
|
||||
|
||||
final target0 = _random.nextInt(10);
|
||||
final target1 = _random.nextInt(10);
|
||||
final target2 = _random.nextInt(10);
|
||||
// Local CSPRNG is the fallback. RANDOM.ORG replaces all three faces
|
||||
// together if it arrives before the first slot locks (up to ~960ms).
|
||||
var target0 = _flicker.nextInt(10);
|
||||
var target1 = _flicker.nextInt(10);
|
||||
var target2 = _flicker.nextInt(10);
|
||||
var source = RandomSource.local;
|
||||
DiceRoll? remoteRoll;
|
||||
|
||||
int ticks = 0;
|
||||
unawaited(
|
||||
TrueRandomService.instance.take(3).then((roll) {
|
||||
remoteRoll = roll;
|
||||
}, onError: (Object _, StackTrace _) {}),
|
||||
);
|
||||
|
||||
var locked0 = false;
|
||||
var locked1 = false;
|
||||
var locked2 = false;
|
||||
var lock0At = 10;
|
||||
var lock1At = 18;
|
||||
var lock2At = 26;
|
||||
var ticks = 0;
|
||||
const interval = Duration(milliseconds: 60);
|
||||
|
||||
_rollTimer?.cancel();
|
||||
_rollTimer = Timer.periodic(interval, (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
ticks++;
|
||||
|
||||
// Slot 0 stops after 10 ticks (~600ms)
|
||||
if (ticks < 10) {
|
||||
setState(() => _animatedDigit0 = _random.nextInt(10));
|
||||
} else if (ticks == 10) {
|
||||
setState(() => _animatedDigit0 = target0);
|
||||
HapticFeedback.mediumImpact();
|
||||
if (!locked0) {
|
||||
final ready = remoteRoll != null && remoteRoll!.digits.length == 3;
|
||||
if (ticks >= 10 && (ready || ticks >= 16)) {
|
||||
if (ready) {
|
||||
target0 = remoteRoll!.digits[0];
|
||||
target1 = remoteRoll!.digits[1];
|
||||
target2 = remoteRoll!.digits[2];
|
||||
source = remoteRoll!.source;
|
||||
}
|
||||
locked0 = true;
|
||||
lock0At = ticks;
|
||||
lock1At = lock0At + 8;
|
||||
lock2At = lock1At + 8;
|
||||
setState(() => _animatedDigit0 = target0);
|
||||
HapticFeedback.mediumImpact();
|
||||
} else {
|
||||
setState(() => _animatedDigit0 = _flicker.nextInt(10));
|
||||
}
|
||||
}
|
||||
|
||||
// Slot 1 stops after 18 ticks (~1080ms)
|
||||
if (ticks < 18) {
|
||||
setState(() => _animatedDigit1 = _random.nextInt(10));
|
||||
} else if (ticks == 18) {
|
||||
setState(() => _animatedDigit1 = target1);
|
||||
HapticFeedback.mediumImpact();
|
||||
if (!locked1) {
|
||||
if (locked0 && ticks >= lock1At) {
|
||||
locked1 = true;
|
||||
setState(() => _animatedDigit1 = target1);
|
||||
HapticFeedback.mediumImpact();
|
||||
} else {
|
||||
setState(() => _animatedDigit1 = _flicker.nextInt(10));
|
||||
}
|
||||
}
|
||||
|
||||
// Slot 2 stops after 26 ticks (~1560ms)
|
||||
if (ticks < 26) {
|
||||
setState(() => _animatedDigit2 = _random.nextInt(10));
|
||||
} else if (ticks == 26) {
|
||||
setState(() => _animatedDigit2 = target2);
|
||||
HapticFeedback.heavyImpact();
|
||||
if (!locked2) {
|
||||
if (locked1 && ticks >= lock2At) {
|
||||
locked2 = true;
|
||||
setState(() => _animatedDigit2 = target2);
|
||||
HapticFeedback.heavyImpact();
|
||||
} else {
|
||||
setState(() => _animatedDigit2 = _flicker.nextInt(10));
|
||||
}
|
||||
}
|
||||
|
||||
if (ticks >= 28) {
|
||||
if (locked2 && ticks >= lock2At + 2) {
|
||||
timer.cancel();
|
||||
setState(() => _internalRolling = false);
|
||||
final finalNumber = target0 * 100 + target1 * 10 + target2;
|
||||
widget.onRollCompleted(finalNumber);
|
||||
setState(() {
|
||||
_internalRolling = false;
|
||||
_lastSource = source;
|
||||
});
|
||||
widget.onRollCompleted(target0 * 100 + target1 * 10 + target2);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -141,7 +187,11 @@ class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
|
||||
Text(
|
||||
_internalRolling
|
||||
? 'Бросок...'
|
||||
: (widget.digits.length == 3 ? 'Зафиксировано' : '3 цифры'),
|
||||
: (widget.digits.length == 3
|
||||
? (_lastSource == RandomSource.randomOrg
|
||||
? 'RANDOM.ORG'
|
||||
: 'Зафиксировано')
|
||||
: '3 цифры'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
Reference in New Issue
Block a user